aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-15 19:28:36 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-15 19:28:36 -0700
commit551e4019b78f418af8fb8ea941ad8d0dac00eecc (patch)
treeddf1991f6c47039af8defdb486ad23fada536abe
parenta3771ecb46e81bb9d996da1e850ec9e4b9ed3c76 (diff)
Fix options lede title style
-rw-r--r--components/options.py500
1 files changed, 329 insertions, 171 deletions
diff --git a/components/options.py b/components/options.py
index b2c98c3..171fc57 100644
--- a/components/options.py
+++ b/components/options.py
@@ -1,200 +1,358 @@
"""Options flow — put/call ratios, IV smile, open interest by strike."""
+from datetime import datetime
+from html import escape as _esc
+
import pandas as pd
-import plotly.graph_objects as go
import streamlit as st
-from services.data_service import get_company_info, get_options_chain
+import streamlit.components.v1 as components
+
+from services.data_service import get_company_info, get_latest_price, get_options_chain
+
+_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"}
+
+
+def _fmt_expiry_label(expiry: str) -> str:
+ try:
+ dt = datetime.strptime(expiry, "%Y-%m-%d")
+ now_year = datetime.now().year
+ if dt.year == now_year:
+ return dt.strftime("%b %d")
+ return dt.strftime("%b %d '%y")
+ except Exception:
+ return expiry
def render_options(ticker: str):
- info = get_company_info(ticker)
- current_price = info.get("currentPrice") or info.get("regularMarketPrice")
+ import json as _json
- with st.spinner("Loading options data…"):
- data = get_options_chain(ticker)
+ info = get_company_info(ticker) or {}
+ price = get_latest_price(ticker)
+ data = get_options_chain(ticker)
if not data or not data.get("chains"):
st.info("Options data unavailable for this ticker.")
return
- expirations = data["expirations"]
- chains = data["chains"]
+ chains = data.get("chains", [])[:8]
+ expirations = data.get("expirations", [])
+ current_price = info.get("currentPrice") or info.get("regularMarketPrice") or price
- # ── Expiry selector ──────────────────────────────────────────────────────
- selected_expiry = st.selectbox(
- "Expiration date",
- options=expirations,
- key=f"options_expiry_{ticker}",
- )
+ def _safe_list(df: pd.DataFrame, col: str) -> list:
+ if col not in df.columns:
+ return []
+ return pd.to_numeric(df[col], errors="coerce").fillna(0).tolist()
- chain_data = next((c for c in chains if c["expiry"] == selected_expiry), None)
- if chain_data is None:
- # Expiry beyond the pre-fetched set — fetch on demand
- try:
- import yfinance as yf
- t = yf.Ticker(ticker.upper())
- chain = t.option_chain(selected_expiry)
- chain_data = {"expiry": selected_expiry, "calls": chain.calls, "puts": chain.puts}
- except Exception:
- st.info("Could not load chain for this expiry.")
- return
+ def _chain_rows(df: pd.DataFrame) -> list[dict]:
+ cols = ["strike", "bid", "ask", "lastPrice", "volume", "openInterest", "impliedVolatility", "inTheMoney"]
+ rows = []
+ for _, r in df.iterrows():
+ row = {}
+ for c in cols:
+ if c in df.columns:
+ v = r[c]
+ row[c] = None if pd.isna(v) else v
+ rows.append(row)
+ return rows
- calls: pd.DataFrame = chain_data["calls"].copy()
- puts: pd.DataFrame = chain_data["puts"].copy()
+ chain_list = []
+ for chain in chains:
+ calls = chain.get("calls", pd.DataFrame()).copy()
+ puts = chain.get("puts", pd.DataFrame()).copy()
- # ── Summary metrics ──────────────────────────────────────────────────────
- total_call_vol = float(calls["volume"].sum()) if "volume" in calls.columns else 0.0
- total_put_vol = float(puts["volume"].sum()) if "volume" in puts.columns else 0.0
- total_call_oi = float(calls["openInterest"].sum()) if "openInterest" in calls.columns else 0.0
- total_put_oi = float(puts["openInterest"].sum()) if "openInterest" in puts.columns else 0.0
+ if current_price and not calls.empty:
+ lo, hi = float(current_price) * 0.70, float(current_price) * 1.30
+ calls_atm = calls[(calls["strike"] >= lo) & (calls["strike"] <= hi)] if "strike" in calls.columns else calls
+ puts_atm = puts[(puts["strike"] >= lo) & (puts["strike"] <= hi)] if "strike" in puts.columns else puts
+ else:
+ calls_atm, puts_atm = calls, puts
- pc_vol = total_put_vol / total_call_vol if total_call_vol > 0 else None
- pc_oi = total_put_oi / total_call_oi if total_call_oi > 0 else None
+ total_call_vol = float(pd.to_numeric(calls.get("volume"), errors="coerce").fillna(0).sum()) if "volume" in calls.columns else 0.0
+ total_put_vol = float(pd.to_numeric(puts.get("volume"), errors="coerce").fillna(0).sum()) if "volume" in puts.columns else 0.0
+ total_call_oi = float(pd.to_numeric(calls.get("openInterest"), errors="coerce").fillna(0).sum()) if "openInterest" in calls.columns else 0.0
+ total_put_oi = float(pd.to_numeric(puts.get("openInterest"), errors="coerce").fillna(0).sum()) if "openInterest" in puts.columns else 0.0
- def _pc_delta(val):
- if val is None:
- return None
- if val < 0.7:
- return "Bullish"
- if val < 1.0:
- return "Neutral"
- return "Bearish"
+ entry = {
+ "expiry": chain.get("expiry"),
+ "pc_vol": (total_put_vol / total_call_vol) if total_call_vol > 0 else None,
+ "pc_oi": (total_put_oi / total_call_oi) if total_call_oi > 0 else None,
+ "call_vol": int(total_call_vol),
+ "put_vol": int(total_put_vol),
+ "call_oi": int(total_call_oi),
+ "put_oi": int(total_put_oi),
+ "iv_strikes_calls": _safe_list(calls_atm, "strike"),
+ "iv_calls": _safe_list(calls_atm, "impliedVolatility"),
+ "iv_strikes_puts": _safe_list(puts_atm, "strike"),
+ "iv_puts": _safe_list(puts_atm, "impliedVolatility"),
+ "oi_strikes_calls": _safe_list(calls_atm, "strike"),
+ "oi_calls": _safe_list(calls_atm, "openInterest"),
+ "oi_strikes_puts": _safe_list(puts_atm, "strike"),
+ "oi_puts": _safe_list(puts_atm, "openInterest"),
+ "rows_calls": _chain_rows(calls_atm),
+ "rows_puts": _chain_rows(puts_atm),
+ }
+ chain_list.append(entry)
- m1, m2, m3, m4 = st.columns(4)
- m1.metric(
- "P/C Ratio (Volume)",
- f"{pc_vol:.2f}" if pc_vol is not None else "—",
- delta=_pc_delta(pc_vol),
- delta_color="inverse" if pc_vol and pc_vol >= 1.0 else "normal",
- help="Put/Call volume ratio. >1 = more put activity (bearish bets).",
- )
- m2.metric(
- "P/C Ratio (OI)",
- f"{pc_oi:.2f}" if pc_oi is not None else "—",
- delta=_pc_delta(pc_oi),
- delta_color="inverse" if pc_oi and pc_oi >= 1.0 else "normal",
- help="Put/Call open interest ratio.",
- )
- m3.metric("Total Call Volume", f"{int(total_call_vol):,}" if total_call_vol else "—")
- m4.metric("Total Put Volume", f"{int(total_put_vol):,}" if total_put_vol else "—")
+ first_chain = data["chains"][0] if data.get("chains") else {}
+ calls0 = first_chain.get("calls", pd.DataFrame())
+ if current_price and not calls0.empty and "strike" in calls0.columns:
+ calls0_atm = calls0[(calls0["strike"] >= float(current_price) * 0.70) & (calls0["strike"] <= float(current_price) * 1.30)]
+ else:
+ calls0_atm = calls0
+ n_chain_rows = max(len(calls0_atm), 20)
+ height = 1500 + n_chain_rows * 28
- st.write("")
+ chains_js = "const CHAINS=" + _json.dumps(chain_list) + ";"
+ price_js = "const ATM_PRICE=" + _json.dumps(current_price or 0) + ";"
- # Filter strikes ±30% of current price for cleaner charts
- if current_price and not calls.empty:
- lo, hi = current_price * 0.70, current_price * 1.30
- calls_atm = calls[(calls["strike"] >= lo) & (calls["strike"] <= hi)]
- puts_atm = puts[(puts["strike"] >= lo) & (puts["strike"] <= hi)]
+ prev_close = info.get("previousClose")
+ if current_price and prev_close and prev_close > 0:
+ chg_pct = (float(current_price) - float(prev_close)) / float(prev_close) * 100
+ sign = "+" if chg_pct >= 0 else ""
+ arrow = "▲" if chg_pct >= 0 else "▼"
+ chg_str = arrow + " " + sign + f"{chg_pct:.2f}%"
+ chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg"
else:
- calls_atm = calls
- puts_atm = puts
+ chg_str, chg_cls = "—", ""
- if calls_atm.empty and puts_atm.empty:
- st.info("No near-the-money options found for this expiry.")
- return
+ raw_x = info.get("exchange") or ""
+ exchange = _XMAP.get(raw_x, raw_x) or "—"
+ co_name = _esc(info.get("longName") or info.get("shortName") or ticker.upper())
+ price_str = f"${float(current_price):,.2f}" if current_price else "—"
+ exp_count = len(expirations)
- chart_col1, chart_col2 = st.columns(2)
+ plotly_cdn = "<script src='https://cdn.plot.ly/plotly-2.27.0.min.js'></script>"
+ _ROOT = (
+ "<style>*,*::before,*::after{box-sizing:border-box}"
+ ":root{"
+ "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;"
+ "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;"
+ "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;"
+ "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;"
+ "--oxford:#1F3D5C;--oxford-light:#2E5A87;"
+ "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;"
+ "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;"
+ "--font-display:'EB Garamond',Georgia,serif;"
+ "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;"
+ "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;"
+ "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;"
+ "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;"
+ "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;"
+ "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;"
+ "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;"
+ "--shadow-1:0 1px 3px rgba(0,0,0,0.4);"
+ "}"
+ "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);"
+ "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}"
+ "</style>"
+ )
+ fonts_link = (
+ "<link rel='preconnect' href='https://fonts.googleapis.com'>"
+ "<link href='https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500"
+ "&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'"
+ " rel='stylesheet'>"
+ )
+
+ _OPTS_CSS = """<style>
+.opts-wrap{background:var(--ink-0);min-height:100vh}
+.num{font-family:var(--font-mono);font-variant-numeric:tabular-nums}
+.eyebrow-lbl{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600}
+.val-ctx{display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1);background:var(--ink-1)}
+.val-ctx .sym{font-family:var(--font-display);font-size:var(--fs-24);font-weight:500;letter-spacing:var(--tr-tight)}
+.val-ctx .name{font-family:var(--font-display);font-style:italic;font-size:var(--fs-16);color:var(--fg-2);margin-left:calc(-1 * var(--sp-1));white-space:nowrap}
+.val-ctx .eyebrow-ctx{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap}
+.val-ctx .meta{display:flex;gap:var(--sp-4);margin-left:auto;font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)}
+.val-ctx .meta span{white-space:nowrap}
+.val-ctx .meta .px{color:var(--fg-1);font-size:var(--fs-14)}
+.val-ctx .meta .chg-pos{color:var(--positive)}.val-ctx .meta .chg-neg{color:var(--negative)}
+.opts-body{padding:var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-5)}
+.opts-lede{display:grid;grid-template-columns:1.6fr 1fr;gap:var(--sp-5);align-items:stretch;background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-5)}
+.opts-lede .left{display:flex;flex-direction:column;gap:var(--sp-2)}
+.opts-lede .ttl{font-family:var(--font-display);font-size:var(--fs-30);font-weight:500;letter-spacing:var(--tr-tight);line-height:1.1;color:var(--fg-1);margin:var(--sp-1) 0 0}
+.opts-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:64ch;margin:0}
+.opts-lede .right{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);align-content:end}
+.kr-source{display:flex;flex-direction:column;gap:2px;padding:var(--sp-3) var(--sp-4);background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2)}
+.kr-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600}
+.kr-source .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-14);color:var(--fg-1);font-weight:500}
+.kr-source .cap{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
+.opts-expiry-bar{display:flex;flex-wrap:wrap;gap:var(--sp-2);padding:var(--sp-3) var(--sp-4);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3)}
+.opts-exp-btn{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;padding:5px 14px;border-radius:var(--r-2);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer;letter-spacing:var(--tr-wide)}
+.opts-exp-btn:hover{background:var(--ink-3);color:var(--fg-2)}
+.opts-exp-btn.active{background:var(--ink-3);color:var(--brass);border-color:var(--brass-deep)}
+.opts-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-3)}
+.opts-kpi{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-1)}
+.opts-kpi .lbl{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:500}
+.opts-kpi .v{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-24);color:var(--fg-1);line-height:1.1}
+.opts-badge{display:inline-flex;align-items:center;align-self:flex-start;padding:2px 8px;border-radius:var(--r-full);font-family:var(--font-sans);font-size:var(--fs-12);font-weight:600}
+.opts-badge.bull{color:var(--positive);background:var(--positive-bg)}
+.opts-badge.neu{color:var(--warning);background:var(--warning-bg)}
+.opts-badge.bear{color:var(--negative);background:var(--negative-bg)}
+.opts-charts{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)}
+.opts-chart-box{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-2) var(--sp-2) var(--sp-1)}
+.opts-chain-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden}
+.opts-chain-controls{display:flex;gap:var(--sp-1);padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--line-1)}
+.opts-side-btn{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;padding:5px 14px;border-radius:var(--r-2);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer;letter-spacing:var(--tr-wide)}
+.opts-side-btn:hover{background:var(--ink-3);color:var(--fg-2)}
+.opts-side-btn.active{background:var(--ink-3);color:var(--brass);border-color:var(--brass-deep)}
+.opts-chain-table{display:flex;flex-direction:column}
+.opts-grid-head,.opts-grid-row{display:grid;grid-template-columns:0.8fr 0.7fr 0.7fr 0.8fr 1fr 1fr 0.8fr 0.6fr}
+.opts-grid-head{background:var(--ink-2);border-bottom:1px solid var(--line-1)}
+.opts-grid-head div{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);padding:8px var(--sp-3);font-weight:600;text-align:right}
+.opts-grid-row{border-bottom:1px solid var(--line-1)}
+.opts-grid-row:last-child{border-bottom:none}
+.opts-grid-row.itm{border-left:3px solid var(--oxford-light)}
+.opts-grid-row div{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:var(--fs-13);color:var(--fg-2);padding:10px var(--sp-3);text-align:right}
+.opts-grid-empty{padding:var(--sp-6);font-family:var(--font-sans);font-size:var(--fs-14);color:var(--fg-3);text-align:center}
+.va-foot{font-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-3);line-height:1.6;padding:var(--sp-3) var(--sp-5);border:1px solid var(--line-1);border-radius:var(--r-2);background:var(--ink-1)}
+@media (max-width:1100px){
+ .opts-lede,.opts-charts{grid-template-columns:1fr}
+ .opts-lede .right{grid-template-columns:1fr}
+ .opts-kpis{grid-template-columns:1fr 1fr}
+}
+</style>"""
+
+ ctx_html = (
+ '<div class="val-ctx">'
+ '<span class="sym">' + ticker.upper() + "</span>"
+ '<span class="name">' + co_name + "</span>"
+ '<span class="eyebrow-ctx" style="margin-left:12px">Options</span>'
+ '<div class="meta">'
+ "<span>" + _esc(exchange) + "</span>"
+ '<span class="px num">' + price_str + "</span>"
+ '<span class="' + chg_cls + ' num">' + chg_str + "</span>"
+ "</div></div>"
+ )
- # ── IV Smile ─────────────────────────────────────────────────────────────
- with chart_col1:
- if "impliedVolatility" in calls_atm.columns:
- st.markdown("**Implied Volatility Smile**")
- fig_iv = go.Figure()
- fig_iv.add_trace(go.Scatter(
- x=calls_atm["strike"],
- y=calls_atm["impliedVolatility"] * 100,
- name="Calls IV",
- mode="lines+markers",
- line=dict(color="#C2AA7A", width=2),
- marker=dict(size=4),
- ))
- if not puts_atm.empty and "impliedVolatility" in puts_atm.columns:
- fig_iv.add_trace(go.Scatter(
- x=puts_atm["strike"],
- y=puts_atm["impliedVolatility"] * 100,
- name="Puts IV",
- mode="lines+markers",
- line=dict(color="#C49545", width=2),
- marker=dict(size=4),
- ))
- if current_price:
- fig_iv.add_vline(
- x=current_price,
- line_dash="dash",
- line_color="rgba(255,255,255,0.35)",
- annotation_text="ATM",
- annotation_position="top",
- )
- fig_iv.update_layout(
- yaxis_title="Implied Volatility (%)",
- xaxis_title="Strike",
- plot_bgcolor="rgba(0,0,0,0)",
- paper_bgcolor="rgba(0,0,0,0)",
- margin=dict(l=0, r=0, t=10, b=0),
- height=300,
- legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
- hovermode="x unified",
- )
- st.plotly_chart(fig_iv, width="stretch")
+ lede_html = (
+ '<section class="opts-lede">'
+ '<div class="left">'
+ '<span class="eyebrow-lbl">Derivatives</span>'
+ '<div class="ttl">The market&#39;s forward view</div>'
+ '<p class="sub">Put/call ratios, implied volatility smile, and open interest by strike for '
+ + ticker.upper()
+ + ". Data sourced from Yahoo Finance via yfinance.</p>"
+ "</div>"
+ '<div class="right">'
+ '<div class="kr-source"><span class="lbl">Source</span><span class="v">yfinance</span><span class="cap">Yahoo Finance API</span></div>'
+ '<div class="kr-source"><span class="lbl">Expirations</span><span class="v num">' + str(exp_count) + "</span><span class=\"cap\">available</span></div>"
+ '<div class="kr-source"><span class="lbl">Spot</span><span class="v num">' + price_str + "</span><span class=\"cap\">latest</span></div>"
+ "</div></section>"
+ )
- # ── Open Interest by strike ───────────────────────────────────────────────
- with chart_col2:
- if "openInterest" in calls_atm.columns:
- st.markdown("**Open Interest by Strike**")
- fig_oi = go.Figure()
- fig_oi.add_trace(go.Bar(
- x=calls_atm["strike"],
- y=calls_atm["openInterest"].fillna(0),
- name="Calls OI",
- marker_color="rgba(79,142,247,0.75)",
- ))
- if not puts_atm.empty and "openInterest" in puts_atm.columns:
- fig_oi.add_trace(go.Bar(
- x=puts_atm["strike"],
- y=-puts_atm["openInterest"].fillna(0),
- name="Puts OI",
- marker_color="rgba(247,162,79,0.75)",
- ))
- if current_price:
- fig_oi.add_vline(
- x=current_price,
- line_dash="dash",
- line_color="rgba(255,255,255,0.35)",
- annotation_text="ATM",
- annotation_position="top",
- )
- fig_oi.update_layout(
- barmode="overlay",
- yaxis_title="Open Interest (puts mirrored)",
- xaxis_title="Strike",
- plot_bgcolor="rgba(0,0,0,0)",
- paper_bgcolor="rgba(0,0,0,0)",
- margin=dict(l=0, r=0, t=10, b=0),
- height=300,
- legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
- )
- st.plotly_chart(fig_oi, width="stretch")
+ expiry_btns = ""
+ for i, c in enumerate(chains):
+ lbl = _fmt_expiry_label(str(c.get("expiry", "")))
+ expiry_btns += (
+ '<button class="opts-exp-btn'
+ + (" active" if i == 0 else "")
+ + '" onclick="selectExpiry('
+ + str(i)
+ + ')">'
+ + _esc(lbl)
+ + "</button>"
+ )
+ expiry_bar_html = '<div class="opts-expiry-bar">' + expiry_btns + "</div>"
- # ── Raw chain table ───────────────────────────────────────────────────────
- with st.expander("Full options chain"):
- tab_calls, tab_puts = st.tabs(["Calls", "Puts"])
- display_cols = ["strike", "lastPrice", "bid", "ask", "volume", "openInterest", "impliedVolatility"]
+ init_js = (
+ "<script>"
+ + chains_js
+ + price_js
+ + 'var activeExpiry=0;var activeSide="calls";'
+ + "function cssVar(n){return getComputedStyle(document.documentElement).getPropertyValue(n).trim();}"
+ + "function withAlpha(hex,a){if(!hex||hex.charAt(0)!=='#'||hex.length!==7)return 'rgba(0,0,0,'+a+')';"
+ + "var r=parseInt(hex.substring(1,3),16),g=parseInt(hex.substring(3,5),16),b=parseInt(hex.substring(5,7),16);"
+ + "return 'rgba('+r+','+g+','+b+','+a+')';}"
+ + "var C_BRASS=cssVar('--brass');var C_WARN=cssVar('--warning');var C_OX=cssVar('--oxford-light');"
+ + "var C_NEG=cssVar('--negative');var C_LINE=cssVar('--line-1');var C_FG3=cssVar('--fg-3');"
+ + "var C_FG1=cssVar('--fg-1');var ATM_LINE=withAlpha(C_FG1,0.25);"
+ + "function fmtNum(v,d){if(v===null||v===undefined||v==='')return '—';var n=Number(v);if(!isFinite(n))return '—';return n.toFixed(d);}"
+ + "function fmtInt(v){if(v===null||v===undefined||v==='')return '—';var n=Number(v);if(!isFinite(n))return '—';return Math.round(n).toLocaleString();}"
+ + "function sentiment(pc){if(pc===null||pc===undefined||!isFinite(Number(pc)))return {label:'—',cls:'neu'};"
+ + "pc=Number(pc);if(pc<0.7)return {label:'Bullish',cls:'bull'};if(pc<=1.0)return {label:'Neutral',cls:'neu'};return {label:'Bearish',cls:'bear'};}"
+ + "function renderKPIs(d){var s1=sentiment(d.pc_vol),s2=sentiment(d.pc_oi);"
+ + "var html='';"
+ + "html+='<div class=\"opts-kpi\"><div class=\"lbl\">P/C Volume</div><div class=\"v\">'+(d.pc_vol==null?'—':fmtNum(d.pc_vol,2))+'</div><span class=\"opts-badge '+s1.cls+'\">'+s1.label+'</span></div>';"
+ + "html+='<div class=\"opts-kpi\"><div class=\"lbl\">P/C Open Interest</div><div class=\"v\">'+(d.pc_oi==null?'—':fmtNum(d.pc_oi,2))+'</div><span class=\"opts-badge '+s2.cls+'\">'+s2.label+'</span></div>';"
+ + "html+='<div class=\"opts-kpi\"><div class=\"lbl\">Call Volume</div><div class=\"v\">'+fmtInt(d.call_vol)+'</div></div>';"
+ + "html+='<div class=\"opts-kpi\"><div class=\"lbl\">Put Volume</div><div class=\"v\">'+fmtInt(d.put_vol)+'</div></div>';"
+ + "document.getElementById('opts-kpis').innerHTML=html;}"
+ + "function renderCharts(d){"
+ + "if(typeof Plotly==='undefined'){return;}"
+ + "var ivData=["
+ + "{x:d.iv_strikes_calls,y:(d.iv_calls||[]).map(function(v){return Number(v||0)*100;}),name:'Calls',type:'scatter',mode:'lines+markers',line:{color:C_BRASS,width:2},marker:{size:4,color:C_BRASS}},"
+ + "{x:d.iv_strikes_puts,y:(d.iv_puts||[]).map(function(v){return Number(v||0)*100;}),name:'Puts',type:'scatter',mode:'lines+markers',line:{color:C_WARN,width:2},marker:{size:4,color:C_WARN}}"
+ + "];"
+ + "var ivShapes=[];if(Number(ATM_PRICE)>0){ivShapes.push({type:'line',xref:'x',yref:'paper',x0:Number(ATM_PRICE),x1:Number(ATM_PRICE),y0:0,y1:1,line:{color:ATM_LINE,width:1,dash:'dot'}});}"
+ + "var LAYOUT_IV={plot_bgcolor:'rgba(0,0,0,0)',paper_bgcolor:'rgba(0,0,0,0)',margin:{l:48,r:12,t:16,b:40},height:280,"
+ + "font:{family:'IBM Plex Mono',color:C_FG3},xaxis:{title:'Strike',gridcolor:C_LINE,zerolinecolor:C_LINE},yaxis:{title:'IV (%)',gridcolor:C_LINE,zerolinecolor:C_LINE},shapes:ivShapes,legend:{orientation:'h',y:-0.25,x:0}};"
+ + "Plotly.react('iv-chart',ivData,LAYOUT_IV,{displayModeBar:false,responsive:true});"
+ + "var oiData=["
+ + "{x:d.oi_strikes_calls,y:(d.oi_calls||[]).map(function(v){return Number(v||0);}),name:'Calls',type:'bar',marker:{color:C_OX}},"
+ + "{x:d.oi_strikes_puts,y:(d.oi_puts||[]).map(function(v){return -Number(v||0);}),name:'Puts',type:'bar',marker:{color:C_NEG}}"
+ + "];"
+ + "var oiShapes=[];if(Number(ATM_PRICE)>0){oiShapes.push({type:'line',xref:'x',yref:'paper',x0:Number(ATM_PRICE),x1:Number(ATM_PRICE),y0:0,y1:1,line:{color:ATM_LINE,width:1,dash:'dot'}});}"
+ + "var LAYOUT_OI={plot_bgcolor:'rgba(0,0,0,0)',paper_bgcolor:'rgba(0,0,0,0)',margin:{l:48,r:12,t:16,b:40},height:280,barmode:'relative',"
+ + "font:{family:'IBM Plex Mono',color:C_FG3},xaxis:{title:'Strike',gridcolor:C_LINE,zerolinecolor:C_LINE},yaxis:{title:'Open Interest',gridcolor:C_LINE,zerolinecolor:C_LINE},shapes:oiShapes,legend:{orientation:'h',y:-0.25,x:0}};"
+ + "Plotly.react('oi-chart',oiData,LAYOUT_OI,{displayModeBar:false,responsive:true});}"
+ + "function rowCell(v,kind){if(v===null||v===undefined||v==='')return '—';"
+ + "if(kind==='int'){var n=Number(v);return isFinite(n)?Math.round(n).toLocaleString():'—';}"
+ + "if(kind==='pct'){var p=Number(v);return isFinite(p)?(p*100).toFixed(1)+'%':'—';}"
+ + "var n2=Number(v);if(isFinite(n2))return n2.toFixed(2);return String(v);}"
+ + "function renderTable(d,side){var rows=(side==='puts')?(d.rows_puts||[]):(d.rows_calls||[]);"
+ + "var head='<div class=\"opts-grid-head\"><div>Strike</div><div>Bid</div><div>Ask</div><div>Last</div><div>Volume</div><div>OI</div><div>IV%</div><div>ITM</div></div>';"
+ + "if(!rows.length){document.getElementById('opts-chain-table').innerHTML=head+'<div class=\"opts-grid-empty\">No options rows in ±30% strike band.</div>';return;}"
+ + "var body='';rows.forEach(function(r){var itm=!!r.inTheMoney;body+='<div class=\"opts-grid-row'+(itm?' itm':'')+'\">';"
+ + "body+='<div>'+rowCell(r.strike,'num')+'</div>';"
+ + "body+='<div>'+rowCell(r.bid,'num')+'</div>';"
+ + "body+='<div>'+rowCell(r.ask,'num')+'</div>';"
+ + "body+='<div>'+rowCell(r.lastPrice,'num')+'</div>';"
+ + "body+='<div>'+rowCell(r.volume,'int')+'</div>';"
+ + "body+='<div>'+rowCell(r.openInterest,'int')+'</div>';"
+ + "body+='<div>'+rowCell(r.impliedVolatility,'pct')+'</div>';"
+ + "body+='<div>'+(itm?'Yes':'No')+'</div>';"
+ + "body+='</div>';});"
+ + "document.getElementById('opts-chain-table').innerHTML=head+body;}"
+ + "function selectExpiry(i){activeExpiry=i;var d=CHAINS[i]||CHAINS[0];if(!d){return;}"
+ + "document.querySelectorAll('.opts-exp-btn').forEach(function(b,j){b.classList.toggle('active',j===i);});"
+ + "renderKPIs(d);renderCharts(d);renderTable(d,activeSide);}"
+ + "function switchSide(side,btn){activeSide=side;document.querySelectorAll('.opts-side-btn').forEach(function(b){b.classList.remove('active');});btn.classList.add('active');renderTable(CHAINS[activeExpiry],side);}"
+ + "function bootOptions(){if(!CHAINS||!CHAINS.length){return;}selectExpiry(0);if(typeof Plotly==='undefined'){setTimeout(function(){selectExpiry(activeExpiry);},300);setTimeout(function(){selectExpiry(activeExpiry);},900);}}"
+ + "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',bootOptions);}else{bootOptions();}"
+ + "</script>"
+ )
- with tab_calls:
- show_cols = [c for c in display_cols if c in calls.columns]
- if show_cols:
- df_show = calls[show_cols].copy()
- if "impliedVolatility" in df_show.columns:
- df_show["impliedVolatility"] = df_show["impliedVolatility"].apply(
- lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—"
- )
- st.dataframe(df_show, width="stretch", hide_index=True)
+ foot_html = (
+ '<div class="va-foot">'
+ "<span>Options data provided by Yahoo Finance via yfinance · Strike range filtered to ±30% of spot price · Open interest and volume as of last close</span>"
+ "</div>"
+ )
+
+ doc = (
+ "<!doctype html><html><head><meta charset='utf-8'>"
+ + plotly_cdn
+ + fonts_link
+ + _ROOT
+ + _OPTS_CSS
+ + "</head><body><div class='opts-wrap'>"
+ + ctx_html
+ + '<div class="opts-body">'
+ + lede_html
+ + expiry_bar_html
+ + '<div id="opts-kpis" class="opts-kpis"></div>'
+ + '<div class="opts-charts">'
+ + '<div id="iv-chart" class="opts-chart-box"></div>'
+ + '<div id="oi-chart" class="opts-chart-box"></div>'
+ + "</div>"
+ + '<div class="opts-chain-card">'
+ + '<div class="opts-chain-controls">'
+ + '<button class="opts-side-btn active" onclick="switchSide(\'calls\',this)">Calls</button>'
+ + '<button class="opts-side-btn" onclick="switchSide(\'puts\',this)">Puts</button>'
+ + "</div>"
+ + '<div id="opts-chain-table" class="opts-chain-table"></div>'
+ + "</div>"
+ + foot_html
+ + "</div></div>"
+ + init_js
+ + "</body></html>"
+ )
- with tab_puts:
- show_cols = [c for c in display_cols if c in puts.columns]
- if show_cols:
- df_show = puts[show_cols].copy()
- if "impliedVolatility" in df_show.columns:
- df_show["impliedVolatility"] = df_show["impliedVolatility"].apply(
- lambda v: f"{v*100:.1f}%" if pd.notna(v) else "—"
- )
- st.dataframe(df_show, width="stretch", hide_index=True)
+ components.html(doc, height=height, scrolling=False)