diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-15 19:28:36 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-15 19:28:36 -0700 |
| commit | 551e4019b78f418af8fb8ea941ad8d0dac00eecc (patch) | |
| tree | ddf1991f6c47039af8defdb486ad23fada536abe /components/options.py | |
| parent | a3771ecb46e81bb9d996da1e850ec9e4b9ed3c76 (diff) | |
Fix options lede title style
Diffstat (limited to 'components/options.py')
| -rw-r--r-- | components/options.py | 500 |
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'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) |
