From 40331a9778968152ec5fd5178a38dde13b4b5b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:02:59 +0000 Subject: [PATCH 1/3] Add plain-English explanations across all analytical pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize column tooltips in app_lib (COLUMN_HELP + column_help) and wire them into every data table — options chain, SVI surface params, strategy candidates, POP-by-measure, screener, and backtest trades. Add help= tooltips to the headline metrics (Overview, Vol Diagnostics, Probability Lab, Backtester) and short "how to read" captions under the jargon-heavy tables and charts (cone, payoff, P&L surface, radar, SVI). https://claude.ai/code/session_01AXbuChtmt8Xo4cxEtKrady --- app_lib.py | 84 ++++++++++++++++++++++++++++++++++ pages/10_Watchlist_Screener.py | 14 +++++- pages/11_Advanced_Models.py | 4 +- pages/1_Ticker_Overview.py | 13 +++++- pages/2_Options_Chain.py | 12 ++++- pages/3_IV_Surface.py | 14 +++++- pages/4_Vol_Diagnostics.py | 38 +++++++++++++-- pages/5_Strategy_Generator.py | 19 +++++++- pages/6_Strategy_Optimizer.py | 13 +++++- pages/7_Payoff_and_Scenario.py | 9 ++++ pages/8_Probability_Lab.py | 19 ++++++-- pages/9_Backtester.py | 64 ++++++++++++++++++-------- 12 files changed, 267 insertions(+), 36 deletions(-) diff --git a/app_lib.py b/app_lib.py index dc61c31..11a02a2 100644 --- a/app_lib.py +++ b/app_lib.py @@ -181,6 +181,90 @@ def render_chart(fig: Any, **kwargs: Any) -> None: st.plotly_chart(fig, theme=None, **kwargs) +# Plain-English tooltips for dataframe columns across the app, keyed by column +# name. Shared keys (vega, liquidity, expiration) carry the same meaning wherever +# they appear, so one table is enough. +COLUMN_HELP: dict[str, str] = { + # --- strategy candidates --- + "strategy": "Strategy structure (e.g. vertical, iron condor, straddle).", + "expiry": "Expiration date of the (nearest) leg.", + "net": "Net cash to open: positive = debit paid, negative = credit received.", + "credit?": "True if the trade collects net premium at entry.", + "POP (RN)": "Risk-neutral probability of finishing profitable (lognormal at the fitted " + "IV). A market-implied odd, not a real-world forecast.", + "EV": "Risk-neutral expected P&L from Monte Carlo — edge vs fair value, not a " + "real-world profit forecast.", + "max_profit": "Largest possible profit at expiry; ∞ if unbounded.", + "max_loss": "Largest possible loss at expiry; ∞ for uncovered (naked) risk.", + "ES(95%)": "Expected shortfall: average loss in the worst 5% of outcomes (tail risk).", + "ROR": "Return on risk: expected value divided by capital at risk.", + "theta/day": "Time decay per calendar day, in dollars (positive = collects decay).", + "vega": "P&L for a 1 vol-point rise in implied vol (positive = long volatility).", + "liquidity": "0-1 blend of bid/ask spread, open interest, volume and ATM distance " + "(higher = more tradable).", + # --- options chain --- + "expiration": "Option expiration date.", + "right": "C = call, P = put.", + "strike": "Strike price.", + "bid": "Best bid (what buyers will pay).", + "ask": "Best ask (what sellers want).", + "mid": "Midpoint of bid and ask.", + "volume": "Contracts traded today.", + "open_interest": "Open contracts outstanding — a depth/liquidity gauge.", + "iv": "Implied volatility (annualized) backed out from the option's price.", + "delta": "∂price/∂spot — roughly the chance of finishing ITM and the hedge ratio.", + "gamma": "∂delta/∂spot — how fast delta moves as spot moves.", + "theta": "Time decay per day, in dollars per share.", + "spread_pct": "Bid/ask spread as a fraction of mid (lower = tighter, cheaper to trade).", + "zero_bid": "True if there is no bid — you can't sell it.", + "wide_spread": "True if the spread exceeds 10% of mid.", + "is_nonstandard": "True for adjusted/non-standard contracts (e.g. post-split deliverable).", + # --- SVI surface fit --- + "T": "Time to expiry, in years.", + "n": "Number of liquid quotes used in the fit.", + "rmse_vol_pts": "Fit error in vol points (model IV vs market IV).", + "butterfly_free": "True if the smile has no static (butterfly) arbitrage.", + "a": "SVI level — overall height of the total-variance curve.", + "b": "SVI angle — steepness of the two wings.", + "rho": "SVI rotation — skew/asymmetry (negative = downside-heavy).", + "m": "SVI shift — horizontal position of the smile's minimum (in log-moneyness).", + "sigma": "SVI smoothness — how rounded the ATM bottom of the smile is.", + # --- probability-of-profit table --- + "measure": "Probability measure & model: RN = market-implied, P = real-world.", + "POP": "Probability of profit under that measure.", + # --- watchlist screener --- + "symbol": "Ticker.", + "spot": "Underlying price.", + "IV30": "ATM implied vol at ~30 DTE (annualized).", + "RV30": "30-day realized (historical) volatility, Yang-Zhang estimator.", + "IV-RV": "IV30 minus RV30 — variance-risk-premium proxy (positive = options look rich).", + "25dRR": "25-delta risk reversal (call IV − put IV); negative = downside put skew.", + "RVrank": "Where current realized vol sits in its 1-year range (0-1).", + "RVpct": "Percentile of current realized vol over the past year.", + "top_strategy": "Best candidate for this name by the chosen objective.", + "top_POP": "Risk-neutral POP of the top strategy.", + "top_EV": "RN expected value of the top strategy.", + # --- backtest trades --- + "entry": "Trade entry date.", + "exit": "Trade exit date.", + "reason": "Why the position closed (expiry, stop, target, etc.).", + "P&L": "Realized profit/loss for the trade, in dollars.", +} + + +def column_help(columns: object) -> dict[str, Any]: + """Build a Streamlit ``column_config`` of header tooltips for known columns. + + Pass anything iterable of column names (a DataFrame's ``.columns`` or a list). + Unknown columns are left untouched. + """ + return { + str(c): st.column_config.Column(help=COLUMN_HELP[str(c)]) + for c in cast("Any", columns) + if str(c) in COLUMN_HELP + } + + def _load_streamlit_secrets_into_env() -> None: """Bridge Streamlit Cloud secrets into env vars so Settings (OSL_*) reads them. diff --git a/pages/10_Watchlist_Screener.py b/pages/10_Watchlist_Screener.py index 4ed4e14..f7c8590 100644 --- a/pages/10_Watchlist_Screener.py +++ b/pages/10_Watchlist_Screener.py @@ -5,7 +5,7 @@ import pandas as pd import streamlit as st -from app_lib import page_footer, page_header, screen_symbol +from app_lib import column_help, page_footer, page_header, screen_symbol from osl.config import get_settings from osl.strategy.optimizer import OBJECTIVES @@ -42,7 +42,17 @@ progress.empty() if rows: - st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) + screen_df = pd.DataFrame(rows) + st.caption( + "Hover any column header for what it means. **IV-RV** > 0 = options rich " + "vs realized; **25dRR** < 0 = downside put skew." + ) + st.dataframe( + screen_df, + use_container_width=True, + hide_index=True, + column_config=column_help(screen_df.columns), + ) else: st.warning("No symbols could be screened.") diff --git a/pages/11_Advanced_Models.py b/pages/11_Advanced_Models.py index 4aa32c9..d274463 100644 --- a/pages/11_Advanced_Models.py +++ b/pages/11_Advanced_Models.py @@ -87,7 +87,9 @@ trend = ( "rise toward it" if p.theta > p.v0 - else "fall toward it" if p.theta < p.v0 else "stay near it" + else "fall toward it" + if p.theta < p.v0 + else "stay near it" ) if p.rho < -0.05: rho_txt = "spot falls → vol rises (leverage effect), producing a **downside put skew**." diff --git a/pages/1_Ticker_Overview.py b/pages/1_Ticker_Overview.py index 005045e..49964f5 100644 --- a/pages/1_Ticker_Overview.py +++ b/pages/1_Ticker_Overview.py @@ -38,7 +38,11 @@ st.stop() col1, col2, col3 = st.columns(3) -col1.metric(f"{symbol} last", f"{under['last']:,.2f}") +col1.metric( + f"{symbol} last", + f"{under['last']:,.2f}", + help="Last traded price of the underlying.", +) rate, div = rate_assumptions() smiles = prepare_smiles( @@ -52,7 +56,12 @@ nearest = min(smiles, key=lambda s: abs(s.T - target_T)) atm_idx = int(np.argmin(np.abs(nearest.k))) iv30 = float(nearest.iv[atm_idx]) -col2.metric("IV30 (ATM, nearest expiry)", "n/a" if np.isnan(iv30) else f"{iv30:.1%}") +col2.metric( + "IV30 (ATM, nearest expiry)", + "n/a" if np.isnan(iv30) else f"{iv30:.1%}", + help="At-the-money implied vol of the expiry nearest 30 days — the market's " + "expected annualized volatility over roughly the next month.", +) with col3: render_badge( diff --git a/pages/2_Options_Chain.py b/pages/2_Options_Chain.py index 19cc677..2445e78 100644 --- a/pages/2_Options_Chain.py +++ b/pages/2_Options_Chain.py @@ -7,6 +7,7 @@ import streamlit as st from app_lib import ( + column_help, load_chain, load_underlying_dict, page_footer, @@ -97,7 +98,16 @@ "is_nonstandard", ] st.subheader(f"{len(view):,} contracts") -st.dataframe(view[cols], use_container_width=True, hide_index=True) +st.caption( + "Hover any column header for what it means. Greeks: delta ≈ chance ITM, " + "theta = daily decay, vega = sensitivity to a 1-point IV move." +) +st.dataframe( + view[cols], + use_container_width=True, + hide_index=True, + column_config=column_help(cols), +) n_flagged = int(view["zero_bid"].sum() + view["wide_spread"].sum()) if n_flagged: diff --git a/pages/3_IV_Surface.py b/pages/3_IV_Surface.py index f8f5e2c..b16d07e 100644 --- a/pages/3_IV_Surface.py +++ b/pages/3_IV_Surface.py @@ -6,6 +6,7 @@ import streamlit as st from app_lib import ( + column_help, load_chain, load_underlying_dict, page_footer, @@ -76,8 +77,19 @@ render_chart(iv_heatmap(smiles)) st.subheader("SVI parameters per expiry") +st.caption( + "Each expiry's smile is summarised by 5 raw-SVI numbers (hover the headers for " + "each): **a** = level (height), **b** = wing steepness, **ρ** = skew/tilt " + "(negative = downside-heavy), **m** = where the smile bottoms, **σ** = how rounded " + "the bottom is. **rmse_vol_pts** is the fit error; **butterfly_free** flags arbitrage." +) params_df = pd.DataFrame(rows) -st.dataframe(params_df, use_container_width=True, hide_index=True) +st.dataframe( + params_df, + use_container_width=True, + hide_index=True, + column_config=column_help(params_df.columns), +) cal_free = calendar_arbitrage_free( [(s.T, fits[str(s.expiration)]) for s in smiles if str(s.expiration) in fits] diff --git a/pages/4_Vol_Diagnostics.py b/pages/4_Vol_Diagnostics.py index a398dcf..3bd653d 100644 --- a/pages/4_Vol_Diagnostics.py +++ b/pages/4_Vol_Diagnostics.py @@ -74,6 +74,11 @@ if not history.empty: cone = vol_cone(history["close"]) render_chart(vol_cone_chart(cone)) + st.caption( + "How to read: each band shows the historical range of realized vol for a " + "given lookback window (short windows swing wider than long ones). Where the " + "current point sits in the band tells you if vol is high or low for that horizon." + ) st.dataframe(cone, use_container_width=True) else: st.warning("No price history for the cone.") @@ -87,10 +92,24 @@ nearest = min(smiles, key=lambda s: abs(s.T - 30 / 365)) iv30 = float(nearest.iv[int(np.argmin(np.abs(nearest.k)))]) c1, c2, c3 = st.columns(3) - c1.metric("Realized vol (30D, YZ)", "n/a" if np.isnan(rv_now) else f"{rv_now:.1%}") - c2.metric("Implied vol (30D ATM)", "n/a" if np.isnan(iv30) else f"{iv30:.1%}") + c1.metric( + "Realized vol (30D, YZ)", + "n/a" if np.isnan(rv_now) else f"{rv_now:.1%}", + help="How much the stock has actually moved over the last 30 days " + "(annualized, Yang-Zhang estimator).", + ) + c2.metric( + "Implied vol (30D ATM)", + "n/a" if np.isnan(iv30) else f"{iv30:.1%}", + help="How much the options market expects it to move over the next ~30 days.", + ) if not (np.isnan(rv_now) or np.isnan(iv30)): - c3.metric("IV − RV (VRP proxy)", f"{(iv30 - rv_now) * 100:.1f} vol pts") + c3.metric( + "IV − RV (VRP proxy)", + f"{(iv30 - rv_now) * 100:.1f} vol pts", + help="Variance-risk-premium proxy: positive means options are priced " + "above recent realized movement (sellers are compensated for risk).", + ) else: st.warning("No price history for IV/RV.") @@ -101,8 +120,17 @@ if not rv_series.empty: st.subheader("Realized-vol rank (proxy until IV history accumulates)") c1, c2 = st.columns(2) - c1.metric("RV rank", f"{iv_rank(rv_series):.0%}") - c2.metric("RV percentile", f"{iv_percentile(rv_series):.0%}") + c1.metric( + "RV rank", + f"{iv_rank(rv_series):.0%}", + help="Where today's realized vol sits between its 1-year low (0%) and high " + "(100%). High = vol is expensive vs its own history.", + ) + c2.metric( + "RV percentile", + f"{iv_percentile(rv_series):.0%}", + help="Share of the past year that realized vol was below today's level.", + ) with st.expander("Model assumptions"): st.markdown( diff --git a/pages/5_Strategy_Generator.py b/pages/5_Strategy_Generator.py index 7136bcc..1557c9c 100644 --- a/pages/5_Strategy_Generator.py +++ b/pages/5_Strategy_Generator.py @@ -7,6 +7,7 @@ from app_lib import ( build_candidates, candidates_table, + column_help, page_footer, page_header, render_chart, @@ -54,10 +55,21 @@ "liquidity_adj_ev": "Liquidity-adjusted EV", } +st.caption( + "Hover any column header for what it means. Read **POP** next to **EV** and " + "**max_loss**: a high probability of profit can still carry negative EV or " + "unbounded loss." +) for obj, label in OBJECTIVE_LABELS.items(): st.subheader(label) top = rank(candidates, obj)[:top_n] - st.dataframe(candidates_table(top), use_container_width=True, hide_index=True) + table = candidates_table(top) + st.dataframe( + table, + use_container_width=True, + hide_index=True, + column_config=column_help(table.columns), + ) # Radar for the single best EV-per-risk candidate. best = rank(candidates, "ev_per_risk")[0] @@ -80,6 +92,11 @@ ["POP", "EV/risk", "Theta", "Vega", "Convexity", "Liquidity"], norm, name=best.strategy.name ), ) +st.caption( + "Each spoke is scaled 0-1 (further out = stronger): POP = chance of profit, " + "EV/risk = edge per dollar risked, Theta = decay collected, Vega = volatility " + "exposure, Convexity = payoff curvature, Liquidity = tradability." +) with st.expander("Model assumptions"): st.markdown( diff --git a/pages/6_Strategy_Optimizer.py b/pages/6_Strategy_Optimizer.py index 4b37bd6..777bebc 100644 --- a/pages/6_Strategy_Optimizer.py +++ b/pages/6_Strategy_Optimizer.py @@ -9,6 +9,7 @@ from app_lib import ( build_candidates, candidates_table, + column_help, page_footer, page_header, render_chart, @@ -54,7 +55,17 @@ ranked = rank(candidates, objective) st.subheader(f"Ranking by {objective} ({len(ranked)} candidates)") -st.dataframe(candidates_table(ranked), use_container_width=True, hide_index=True) +st.caption( + "Hover any column header for what it means. The frontiers below plot EV and " + "POP against max loss so you can see the risk/reward trade-off." +) +ranked_table = candidates_table(ranked) +st.dataframe( + ranked_table, + use_container_width=True, + hide_index=True, + column_config=column_help(ranked_table.columns), +) # Frontiers (bounded-risk candidates only, so axes are finite). bounded = [c for c in ranked if not c.metrics.loss_unbounded and math.isfinite(c.metrics.max_loss)] diff --git a/pages/7_Payoff_and_Scenario.py b/pages/7_Payoff_and_Scenario.py index 6dc8505..16094f3 100644 --- a/pages/7_Payoff_and_Scenario.py +++ b/pages/7_Payoff_and_Scenario.py @@ -73,10 +73,19 @@ def _label(i: int) -> str: name=f"+{shock_days}d, {vol_shock_pts:+d} vol pts", ) render_chart(fig) +st.caption( + "How to read: teal = profit, red = loss at expiry across terminal spot. Gold dashed " + "lines mark breakevens; the off-white line is today's spot. The purple dashed curve " + "(when shown) is the P&L *before* expiry at your chosen days-forward and IV shock." +) st.subheader("P&L surface (spot × time)") sg, dg, z = pnl_grid(strategy, spot_shocks=np.linspace(-0.3, 0.3, 31), vol_shock=vol_shock) render_chart(pnl_surface_chart(sg, dg, z)) +st.caption( + "P&L for every combination of spot (x) and days held (y) — shows how the " + "position's value evolves as time passes and the underlying moves." +) st.subheader("Stress scenarios") iv_crush = st.slider("Earnings IV crush (vol points)", -40, 0, -20) / 100.0 diff --git a/pages/8_Probability_Lab.py b/pages/8_Probability_Lab.py index 9b0827c..25bba6b 100644 --- a/pages/8_Probability_Lab.py +++ b/pages/8_Probability_Lab.py @@ -7,6 +7,7 @@ from app_lib import ( build_candidates, + column_help, garch_forecast, load_chain, load_log_returns, @@ -84,20 +85,32 @@ def _label(i: int) -> str: {"measure": [r[0] for r in rows], "POP": [round(r[1], 3) for r in rows]}, use_container_width=True, hide_index=True, + column_config=column_help(["measure", "POP"]), +) +st.caption( + "How to read: **RN** (risk-neutral) rows are what the *market* charges; **P** " + "(real-world) rows estimate the *actual* odds from history/GARCH. They legitimately " + "differ — the gap is roughly the volatility risk premium. The delta proxy overstates POP." ) c1, c2, c3 = st.columns(3) c1.metric( "EV (RN MC)", f"{m.ev_mc.value:,.0f}", - help=f"95% CI [{m.ev_mc.ci_low:,.0f}, {m.ev_mc.ci_high:,.0f}]", + help=f"Risk-neutral expected P&L (edge vs fair value), Monte Carlo. " + f"95% CI [{m.ev_mc.ci_low:,.0f}, {m.ev_mc.ci_high:,.0f}].", ) c2.metric( "EV (historical)", f"{emp_ev.value:,.0f}", - help=f"95% CI [{emp_ev.ci_low:,.0f}, {emp_ev.ci_high:,.0f}]", + help=f"Expected P&L under the real-world return distribution (historical bootstrap). " + f"95% CI [{emp_ev.ci_low:,.0f}, {emp_ev.ci_high:,.0f}].", +) +c3.metric( + "Expected shortfall (95%)", + f"{m.expected_shortfall:,.0f}", + help="Average loss in the worst 5% of outcomes — a tail-risk companion to EV.", ) -c3.metric("Expected shortfall (95%)", f"{m.expected_shortfall:,.0f}") st.caption(f"GARCH(1,1) annualized vol forecast over {gf.horizon_days}d: {gf.annualized_vol:.1%}") st.subheader("Risk-neutral density") diff --git a/pages/9_Backtester.py b/pages/9_Backtester.py index 09fa2f0..140a7d3 100644 --- a/pages/9_Backtester.py +++ b/pages/9_Backtester.py @@ -7,6 +7,7 @@ from app_lib import ( BACKTEST_SYSTEMS, + column_help, page_footer, page_header, render_chart, @@ -82,16 +83,39 @@ ) c1, c2, c3, c4 = st.columns(4) -c1.metric("Final equity", f"{s['final_equity']:,.0f}") -c2.metric("Total return", f"{s['total_return']:.1%}") -c3.metric("Sharpe (ann.)", f"{s['sharpe']:.2f}") -c4.metric("Max drawdown", f"{s['max_drawdown']:.1%}") +c1.metric("Final equity", f"{s['final_equity']:,.0f}", help="Ending account value.") +c2.metric("Total return", f"{s['total_return']:.1%}", help="Final equity vs starting capital.") +c3.metric( + "Sharpe (ann.)", + f"{s['sharpe']:.2f}", + help="Annualized return per unit of total volatility (>1 is good). Easily inflated " + "by curve-fitting — read it next to DSR.", +) +c4.metric( + "Max drawdown", + f"{s['max_drawdown']:.1%}", + help="Largest peak-to-trough equity decline — the worst losing stretch.", +) c5, c6, c7, c8 = st.columns(4) -c5.metric("Sortino", f"{s['sortino']:.2f}") -c6.metric("PSR (vs 0)", f"{psr:.1%}") -c7.metric("DSR", f"{dsr:.1%}", help=f"Deflated for {int(n_trials)} trials") -c8.metric("Trades", f"{int(s['n_trades'])}") +c5.metric( + "Sortino", + f"{s['sortino']:.2f}", + help="Like Sharpe but penalizes only downside volatility.", +) +c6.metric( + "PSR (vs 0)", + f"{psr:.1%}", + help="Probabilistic Sharpe: confidence the true Sharpe exceeds 0, given sample " + "size, skew and fat tails.", +) +c7.metric( + "DSR", + f"{dsr:.1%}", + help=f"Deflated Sharpe: PSR after deflating for the {int(n_trials)} strategy " + "variants tried — guards against picking a lucky backtest.", +) +c8.metric("Trades", f"{int(s['n_trades'])}", help="Number of closed trades in the run.") if dsr > psr: # guard: DSR must not exceed PSR st.caption("note: DSR ≤ PSR by construction when trials > 1.") st.caption(f"Win rate {s['win_rate']:.0%} · profit factor {s['profit_factor']:.2f}") @@ -101,20 +125,22 @@ st.subheader("Trades") if result.trades: + trades_df = pd.DataFrame( + [ + { + "entry": t.entry_date.isoformat(), + "exit": t.exit_date.isoformat(), + "reason": t.reason, + "P&L": round(t.pnl, 2), + } + for t in result.trades + ] + ) st.dataframe( - pd.DataFrame( - [ - { - "entry": t.entry_date.isoformat(), - "exit": t.exit_date.isoformat(), - "reason": t.reason, - "P&L": round(t.pnl, 2), - } - for t in result.trades - ] - ), + trades_df, use_container_width=True, hide_index=True, + column_config=column_help(trades_df.columns), ) with st.expander("Model assumptions & bias controls"): From b51d13ee91ed8c951b19de3951a55a9f6e026fc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:22:21 +0000 Subject: [PATCH 2/3] Explain the Playbook report and add a central glossary Make the standalone playbook report self-explanatory: header tooltips and a visible "How to read this table" glossary (survives PDF export), plus a fix for the no-candidates colspan. Add a page caption explaining the report and its reproducible digest. Add a Glossary section to Assumptions & Disclaimers covering the volatility, probability, strategy, greek, surface, advanced-model and backtest terms used across the app. https://claude.ai/code/session_01AXbuChtmt8Xo4cxEtKrady --- osl/report/playbook.py | 37 +++++++++++++--- pages/12_Playbook.py | 6 +++ pages/99_Assumptions_and_Disclaimers.py | 59 +++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/osl/report/playbook.py b/osl/report/playbook.py index b896562..b81223f 100644 --- a/osl/report/playbook.py +++ b/osl/report/playbook.py @@ -12,6 +12,24 @@ DISCLAIMER = "Research and education only — not investment advice." +# Plain-English definitions for the top-strategies table, used both as hover +# tooltips on the column headers and as a visible glossary (so the standalone +# HTML/PDF report is self-explanatory when shared). +_COLUMN_GLOSSARY: tuple[tuple[str, str], ...] = ( + ("Objective", "The ranking goal this strategy topped (e.g. EV per risk, theta per risk)."), + ("Strategy", "The option structure (vertical, iron condor, strangle, ...)."), + ("Expiry", "Expiration date of the nearest leg."), + ( + "POP (RN)", + "Risk-neutral probability of finishing profitable - market-implied, not a forecast.", + ), + ("EV", "Risk-neutral expected P&L (edge vs fair value), from Monte Carlo."), + ("ES(95%)", "Expected shortfall: average loss in the worst 5% of outcomes (tail risk)."), + ("Max loss", "Largest possible loss at expiry; shown as infinity (uncovered) for naked risk."), + ("Breakevens", "Underlying prices where the position breaks even at expiry."), + ("Liquidity", "0-1 tradability score from spread, open interest, volume and ATM distance."), +) + @dataclass(frozen=True) class StrategyRow: @@ -74,7 +92,7 @@ def _fmt_money(x: float) -> str: def _strategy_rows_html(rows: Sequence[StrategyRow]) -> str: if not rows: - return "No candidates." + return "No candidates." out = [] for r in rows: max_loss = "∞ (uncovered)" if r.loss_unbounded else _fmt_money(r.max_loss) @@ -100,6 +118,12 @@ def build_playbook_html(data: PlaybookData) -> str: digest = report_digest(data) rev = git_revision() assumptions = "".join(f"
  • {escape(a)}
  • " for a in data.assumptions) + header_cells = "".join( + f'{escape(term)}' for term, desc in _COLUMN_GLOSSARY + ) + glossary = "".join( + f"
    {escape(term)}
    {escape(desc)}
    " for term, desc in _COLUMN_GLOSSARY + ) return f""" @@ -116,6 +140,9 @@ def build_playbook_html(data: PlaybookData) -> str: .metrics span {{ display: inline-block; margin-right: 1.5rem; }} footer {{ margin-top: 2rem; color: #888; font-size: 0.75rem; border-top: 1px solid #eee; padding-top: 0.5rem; }} .disclaimer {{ color: #b00; font-weight: bold; }} + .glossary {{ font-size: 0.8rem; color: #444; }} + .glossary dt {{ font-weight: bold; margin-top: 0.4rem; }} + .glossary dd {{ margin: 0 0 0 1rem; }} @@ -132,15 +159,15 @@ def build_playbook_html(data: PlaybookData) -> str:

    Top strategies

    - - - - + {header_cells} {_strategy_rows_html(data.strategies)}
    ObjectiveStrategyExpiryPOP (RN)EVES(95%)Max lossBreakevensLiquidity
    +

    How to read this table

    +
    {glossary}
    +

    Model assumptions

    diff --git a/pages/12_Playbook.py b/pages/12_Playbook.py index 9a9d7d6..431b65f 100644 --- a/pages/12_Playbook.py +++ b/pages/12_Playbook.py @@ -29,6 +29,12 @@ html = build_playbook_html(data) st.success(f"Report digest `{report_digest(data)}` — reproducible for identical inputs.") +st.caption( + "A one-page, shareable summary for this name: current IV level/rank, the surface " + "no-arbitrage note, and the top strategy per objective with POP, EV, tail loss and " + "liquidity. The report carries its own glossary and a content **digest** — the same " + "inputs always produce the same digest, so a report can be verified and reproduced." +) st.download_button( "Download HTML report", diff --git a/pages/99_Assumptions_and_Disclaimers.py b/pages/99_Assumptions_and_Disclaimers.py index 79b6aa2..acc96be 100644 --- a/pages/99_Assumptions_and_Disclaimers.py +++ b/pages/99_Assumptions_and_Disclaimers.py @@ -60,3 +60,62 @@ Strategy generation/optimization, payoff & scenario analysis, the probability lab, GARCH forecasts, PCA/Heston, and the snapshot-driven backtester. """) + +st.markdown(""" +### Glossary + +Plain-English definitions of the terms used across the app. Hover the column +headers and metric labels on each page for the same explanations in context. + +**Volatility** + +- **IV (implied volatility)** — the annualized volatility an option's market price implies under Black-Scholes. +- **IV30** — ATM IV of the expiry nearest 30 days; the market's expected vol over roughly one month. +- **RV (realized volatility)** — how much the underlying actually moved historically (Yang-Zhang by default). +- **IV rank / percentile** — where current vol sits in its 1-year range (rank), or the share of the year it was lower (percentile). +- **IV minus RV (VRP proxy)** — variance-risk-premium proxy; positive means options are priced above realized movement. +- **Term structure (contango / backwardation)** — ATM IV rising / falling as expiry lengthens. +- **25-delta risk reversal** — 25-delta call IV minus put IV; negative = downside put skew. + +**Probability & expected value** + +- **POP** — probability of profit. +- **Risk-neutral (RN) vs real-world (P)** — RN is market-implied (what options charge); P estimates actual odds from history/GARCH. The gap is roughly the volatility risk premium. +- **EV (expected value)** — expected P&L. RN EV is edge vs fair value; historical EV uses the real-world return distribution. +- **Expected shortfall (ES, 95%)** — average loss in the worst 5% of outcomes (tail risk). +- **Risk-neutral density (RND)** — the distribution of future prices implied by option prices (Breeden-Litzenberger). + +**Strategy metrics** + +- **Max loss / max profit** — worst / best outcome at expiry; infinity = uncovered (unbounded) risk. +- **Breakeven** — underlying price where P&L is zero at expiry. +- **ROR (return on risk)** — expected value divided by capital at risk. +- **Liquidity score (0-1)** — blend of bid/ask spread, open interest, volume and ATM distance. +- **Credit vs debit** — net premium collected vs paid to open. + +**Greeks** + +- **Delta** — sensitivity to spot; roughly the chance of finishing in the money. +- **Gamma** — how fast delta changes as spot moves. +- **Theta** — time decay per calendar day. +- **Vega** — P&L for a 1 vol-point change in IV. +- **Rho** — sensitivity to a 1.00 change in the interest rate. + +**Surface (SVI)** + +- **SVI a / b / rho / m / sigma** — raw-SVI smile parameters: level, wing steepness, skew/tilt, location of the minimum, and ATM curvature. +- **Butterfly / calendar arbitrage** — static no-arbitrage checks; a flagged fit (especially the wings) should not be trusted. + +**Advanced models** + +- **Heston v0 / kappa / theta / sigma / rho** — current variance, mean-reversion speed, long-run variance, vol-of-vol, and spot/vol correlation. +- **Feller condition (2·kappa·theta vs sigma squared)** — when satisfied, variance stays strictly positive; a violation signals a stressed fit. +- **Merton lambda / mu / delta** — jump intensity per year, average jump size, and jump-size dispersion. + +**Backtest statistics** + +- **Sharpe / Sortino** — return per unit of total / downside volatility. +- **PSR (probabilistic Sharpe)** — confidence the true Sharpe exceeds zero, adjusting for sample size, skew and fat tails. +- **DSR (deflated Sharpe)** — PSR deflated for the number of strategy variants tried; guards against a lucky backtest. +- **Max drawdown** — the largest peak-to-trough decline in equity. +""") From 0427f72144dccda3e75089a7b79939a8333fa153 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:52:48 +0000 Subject: [PATCH 3/3] Replace stale "not yet built" list with current limitations Strategy generation, payoff/scenario, the probability lab, GARCH, Heston/ PCA and the backtester all ship now; the old list contradicted the app. Document the real caveats instead: experimental gating, synthetic PCA/ backtest demos until snapshots accumulate, RV-proxy IV rank, and the flat risk-free rate. https://claude.ai/code/session_01AXbuChtmt8Xo4cxEtKrady --- pages/99_Assumptions_and_Disclaimers.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pages/99_Assumptions_and_Disclaimers.py b/pages/99_Assumptions_and_Disclaimers.py index acc96be..2711a13 100644 --- a/pages/99_Assumptions_and_Disclaimers.py +++ b/pages/99_Assumptions_and_Disclaimers.py @@ -55,10 +55,21 @@ - Risk-free rate is a flat configured value in M1; FRED curves and per-name dividends arrive later. -### Not yet built (later milestones) - -Strategy generation/optimization, payoff & scenario analysis, the probability -lab, GARCH forecasts, PCA/Heston, and the snapshot-driven backtester. +### Current limitations + +These features ship today but with caveats worth knowing: + +- **Experimental models** (Heston, Merton jumps, Dupire local vol, surface PCA) + are gated behind `OSL_ENABLE_EXPERIMENTAL` and can be weakly identified on + sparse chains — read each tab's notes. +- **Surface PCA** shows a synthetic 3-factor demo until a multi-day IV-snapshot + history accumulates. +- **IV rank / percentile** use realized vol as a stand-in until a daily IV + history accumulates. +- **Backtests** use synthetic GBM demo data unless real chain snapshots have + been captured (run the snapshot worker); results are illustrative until then. +- **Risk-free rate** is a flat configured value; FRED Treasury curves and + per-name dividend yields arrive later. """) st.markdown("""