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/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
-
- | Objective | Strategy | Expiry | POP (RN) |
- EV | ES(95%) | Max loss | Breakevens | Liquidity |
-
+ {header_cells}
{_strategy_rows_html(data.strategies)}
+How to read this table
+{glossary}
+
Model assumptions
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/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/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/99_Assumptions_and_Disclaimers.py b/pages/99_Assumptions_and_Disclaimers.py
index 79b6aa2..2711a13 100644
--- a/pages/99_Assumptions_and_Disclaimers.py
+++ b/pages/99_Assumptions_and_Disclaimers.py
@@ -55,8 +55,78 @@
- Risk-free rate is a flat configured value in M1; FRED curves and per-name
dividends arrive later.
-### Not yet built (later milestones)
+### 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("""
+### 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**
-Strategy generation/optimization, payoff & scenario analysis, the probability
-lab, GARCH forecasts, PCA/Heston, and the snapshot-driven backtester.
+- **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.
""")
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"):