From 551e4019b78f418af8fb8ea941ad8d0dac00eecc Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 15 May 2026 19:28:36 -0700 Subject: Fix options lede title style --- components/options.py | 516 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 337 insertions(+), 179 deletions(-) (limited to 'components/options.py') 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 + + 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() + + 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 + + chain_list = [] + for chain in chains: + calls = chain.get("calls", pd.DataFrame()).copy() + puts = chain.get("puts", pd.DataFrame()).copy() + + 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 + + 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 + + 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) + + 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 + + chains_js = "const CHAINS=" + _json.dumps(chain_list) + ";" + price_js = "const ATM_PRICE=" + _json.dumps(current_price or 0) + ";" + + 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: + chg_str, chg_cls = "—", "" + + 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) - # ── Expiry selector ────────────────────────────────────────────────────── - selected_expiry = st.selectbox( - "Expiration date", - options=expirations, - key=f"options_expiry_{ticker}", + plotly_cdn = "" + _ROOT = ( + "" ) + fonts_link = ( + "" + "" + ) + + _OPTS_CSS = """""" - 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 - - calls: pd.DataFrame = chain_data["calls"].copy() - puts: pd.DataFrame = chain_data["puts"].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 - - 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 - - def _pc_delta(val): - if val is None: - return None - if val < 0.7: - return "Bullish" - if val < 1.0: - return "Neutral" - return "Bearish" - - 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).", + ctx_html = ( + '
' + '' + ticker.upper() + "" + '' + co_name + "" + 'Options' + '
' + "" + _esc(exchange) + "" + '' + price_str + "" + '' + chg_str + "" + "
" ) - 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.", + + lede_html = ( + '
' + '
' + 'Derivatives' + '
The market's forward view
' + '

Put/call ratios, implied volatility smile, and open interest by strike for ' + + ticker.upper() + + ". Data sourced from Yahoo Finance via yfinance.

" + "
" + '
' + '
SourceyfinanceYahoo Finance API
' + '
Expirations' + str(exp_count) + "available
" + '
Spot' + price_str + "latest
" + "
" ) - 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 "—") - st.write("") + expiry_btns = "" + for i, c in enumerate(chains): + lbl = _fmt_expiry_label(str(c.get("expiry", ""))) + expiry_btns += ( + '" + ) + expiry_bar_html = '
' + expiry_btns + "
" - # 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)] - else: - calls_atm = calls - puts_atm = puts + init_js = ( + "" + ) - if calls_atm.empty and puts_atm.empty: - st.info("No near-the-money options found for this expiry.") - return + foot_html = ( + '
' + "Options data provided by Yahoo Finance via yfinance · Strike range filtered to ±30% of spot price · Open interest and volume as of last close" + "
" + ) + + doc = ( + "" + + plotly_cdn + + fonts_link + + _ROOT + + _OPTS_CSS + + "
" + + ctx_html + + '
' + + lede_html + + expiry_bar_html + + '
' + + '
' + + '
' + + '
' + + "
" + + '
' + + '
' + + '' + + '' + + "
" + + '
' + + "
" + + foot_html + + "
" + + init_js + + "" + ) - chart_col1, chart_col2 = st.columns(2) - - # ── 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") - - # ── 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") - - # ── 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"] - - 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) - - 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) -- cgit v1.3-2-g0d8e