Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions app_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions pages/3_IV_Surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import contextlib

import pandas as pd
import streamlit as st

Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions pages/4_Vol_Diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down
Loading