diff --git a/app_lib.py b/app_lib.py index 11a02a2..55702bc 100644 --- a/app_lib.py +++ b/app_lib.py @@ -265,6 +265,82 @@ def column_help(columns: object) -> dict[str, Any]: } +def skew_reading(rr25: float) -> str: + """One-line read of the 25-delta risk reversal (the skew sign).""" + rr_pts = rr25 * 100 + if rr25 < -0.005: + return ( + f"Downside-heavy skew (25-delta risk reversal {rr_pts:+.1f} pts): OTM puts are bid " + "versus calls — the richness that put credit spreads, put ratios and risk " + "reversals are built to harvest." + ) + if rr25 > 0.005: + return ( + f"Upside-heavy skew (25-delta risk reversal {rr_pts:+.1f} pts, atypical for " + "equities): calls are richer than puts; the mirror-image structures apply." + ) + return ( + f"Balanced skew (25-delta risk reversal {rr_pts:+.1f} pts): little directional vol premium." + ) + + +def trade_reading( + *, + iv30: float, + rv30: float, + rv_rank: float | None = None, + rr25: float | None = None, + term_shape: str | None = None, +) -> str: + """Plain-English "how to read this for trades" framing from live vol signals. + + Educational regime framing only: it names the structures a regime is + *consistent with* (premium-selling when vol is rich, long-gamma when cheap, + skew-harvesting structures for the skew sign), never a recommendation to + enter a trade. Returns a markdown bullet list. + """ + lines: list[str] = [] + if not (np.isnan(iv30) or np.isnan(rv30)): + vrp = (iv30 - rv30) * 100 + rank_txt = ( + f", realized vol in the {rv_rank:.0%} percentile of its year" + if rv_rank is not None and not np.isnan(rv_rank) + else "" + ) + if vrp > 0.5: + lines.append( + f"**Premium looks rich** — IV30 {iv30:.0%} is above realized {rv30:.0%} " + f"(+{vrp:.1f} vol pts{rank_txt}). This regime favors *defined-risk premium " + "selling* (credit spreads, iron condors): you collect the richness with a " + "capped loss." + ) + elif vrp < -0.5: + lines.append( + f"**Premium looks cheap** — IV30 {iv30:.0%} is below realized {rv30:.0%} " + f"({vrp:.1f} vol pts{rank_txt}). This regime favors *premium buying / long " + "gamma* (debit spreads, calendars, long straddles): you pay theta but own " + "convexity." + ) + else: + lines.append( + f"**Vol looks fair** — IV30 {iv30:.0%} is in line with realized {rv30:.0%}; " + "no strong premium edge, so let direction and skew drive the structure." + ) + if rr25 is not None and not np.isnan(rr25): + lines.append(skew_reading(rr25)) + if term_shape == "contango": + lines.append( + "**Term structure in contango** (longer-dated IV higher) — a typical calm regime; " + "calendars and diagonals lean on the cheaper near-dated leg." + ) + elif term_shape == "backwardation": + lines.append( + "**Term structure in backwardation** (near-dated IV higher) — often an event or " + "stress signal; near-dated premium is elevated." + ) + return "\n".join(f"- {ln}" for ln in lines) + + def _load_streamlit_secrets_into_env() -> None: """Bridge Streamlit Cloud secrets into env vars so Settings (OSL_*) reads them. diff --git a/pages/3_IV_Surface.py b/pages/3_IV_Surface.py index b16d07e..505167d 100644 --- a/pages/3_IV_Surface.py +++ b/pages/3_IV_Surface.py @@ -2,6 +2,8 @@ from __future__ import annotations +import contextlib + import pandas as pd import streamlit as st @@ -14,10 +16,12 @@ rate_assumptions, render_chart, sidebar_controls, + skew_reading, ) from osl.surface.prepare import prepare_smiles from osl.surface.svi import SVIParams, calendar_arbitrage_free, fit_svi from osl.viz.charts import iv_heatmap, iv_surface_3d, skew_chart +from osl.volatility.skew import delta_skew_25 page_header("IV Surface") symbol, provider = sidebar_controls() @@ -71,6 +75,11 @@ view = st.radio("View", ["Skew (2D)", "Surface (3D)", "Heatmap"], horizontal=True) if view == "Skew (2D)": render_chart(skew_chart(smiles, fits)) + near30 = min(smiles, key=lambda s: abs(s.T - 30 / 365)) + with contextlib.suppress(Exception): # 25Δ may not be spanned on a thin smile + st.caption( + "Reading this for trades: " + skew_reading(delta_skew_25(near30).risk_reversal_25) + ) elif view == "Surface (3D)": render_chart(iv_surface_3d(smiles)) else: diff --git a/pages/4_Vol_Diagnostics.py b/pages/4_Vol_Diagnostics.py index 3bd653d..9ceb6b4 100644 --- a/pages/4_Vol_Diagnostics.py +++ b/pages/4_Vol_Diagnostics.py @@ -16,12 +16,14 @@ rate_assumptions, render_chart, sidebar_controls, + trade_reading, ) from osl.surface.prepare import prepare_smiles from osl.surface.svi import fit_svi from osl.viz.charts import skew_chart, term_structure_chart, vol_cone_chart from osl.volatility.ranks import iv_percentile, iv_rank, vol_cone from osl.volatility.realized import realized_vol +from osl.volatility.skew import delta_skew_25 page_header("Vol Diagnostics") symbol, provider = sidebar_controls() @@ -44,6 +46,44 @@ rate, div = rate_assumptions() smiles = prepare_smiles(chain, spot=spot, rate=rate, dividend_yield=div) +# Headline synthesis: what the vol picture implies for trade structure. +st.subheader("Reading this for trades") +iv30_now = float("nan") +rr25_now = float("nan") +if smiles: + near30 = min(smiles, key=lambda s: abs(s.T - 30 / 365)) + iv30_now = float(near30.iv[int(np.argmin(np.abs(near30.k)))]) + with contextlib.suppress(Exception): # 25Δ may not be spanned on a thin smile + ds = delta_skew_25(near30) + iv30_now, rr25_now = ds.atm_iv, ds.risk_reversal_25 +rv30_now = float("nan") +rv_rank_now: float | None = None +if not history.empty: + s30 = realized_vol(history, method="yz", window=30).dropna() + if not s30.empty: + rv30_now = float(s30.iloc[-1]) + s20 = realized_vol(history, method="yz", window=20).dropna() + if not s20.empty: + rv_rank_now = iv_rank(s20) +term_shape: str | None = None +if len(smiles) > 1: + by_t = sorted(smiles, key=lambda s: s.T) + atm_short = float(by_t[0].iv[int(np.argmin(np.abs(by_t[0].k)))]) + atm_long = float(by_t[-1].iv[int(np.argmin(np.abs(by_t[-1].k)))]) + term_shape = "contango" if atm_long > atm_short else "backwardation" +note = trade_reading( + iv30=iv30_now, rv30=rv30_now, rv_rank=rv_rank_now, rr25=rr25_now, term_shape=term_shape +) +if note: + st.markdown(note) + st.caption( + "Regime framing for education, not a recommendation. Confirm the odds in the " + "**Probability Lab** (real-world vs risk-neutral POP/EV) and size candidates in the " + "**Strategy Generator** before acting." + ) +else: + st.caption("Not enough data to summarize the vol regime.") + tab_skew, tab_term, tab_cone, tab_ivrv = st.tabs(["Skew", "Term structure", "Vol cone", "IV vs RV"]) with tab_skew: