diff options
Diffstat (limited to 'components/options.py')
| -rw-r--r-- | components/options.py | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/components/options.py b/components/options.py new file mode 100644 index 0000000..e9bf016 --- /dev/null +++ b/components/options.py @@ -0,0 +1,200 @@ +"""Options flow — put/call ratios, IV smile, open interest by strike.""" +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 + + +def render_options(ticker: str): + info = get_company_info(ticker) + current_price = info.get("currentPrice") or info.get("regularMarketPrice") + + with st.spinner("Loading options data…"): + 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"] + + # ── Expiry selector ────────────────────────────────────────────────────── + selected_expiry = st.selectbox( + "Expiration date", + options=expirations, + key=f"options_expiry_{ticker}", + ) + + 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).", + ) + 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 "—") + + st.write("") + + # 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 + + if calls_atm.empty and puts_atm.empty: + st.info("No near-the-money options found for this expiry.") + return + + 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="#4F8EF7", 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="#F7A24F", 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, use_container_width=True) + + # ── 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, use_container_width=True) + + # ── 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, use_container_width=True, 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, use_container_width=True, hide_index=True) |
