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
84 changes: 84 additions & 0 deletions app_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
37 changes: 32 additions & 5 deletions osl/report/playbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -74,7 +92,7 @@ def _fmt_money(x: float) -> str:

def _strategy_rows_html(rows: Sequence[StrategyRow]) -> str:
if not rows:
return "<tr><td colspan='8'>No candidates.</td></tr>"
return "<tr><td colspan='9'>No candidates.</td></tr>"
out = []
for r in rows:
max_loss = "∞ (uncovered)" if r.loss_unbounded else _fmt_money(r.max_loss)
Expand All @@ -100,6 +118,12 @@ def build_playbook_html(data: PlaybookData) -> str:
digest = report_digest(data)
rev = git_revision()
assumptions = "".join(f"<li>{escape(a)}</li>" for a in data.assumptions)
header_cells = "".join(
f'<th title="{escape(desc)}">{escape(term)}</th>' for term, desc in _COLUMN_GLOSSARY
)
glossary = "".join(
f"<dt>{escape(term)}</dt><dd>{escape(desc)}</dd>" for term, desc in _COLUMN_GLOSSARY
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -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; }}
</style>
</head>
<body>
Expand All @@ -132,15 +159,15 @@ def build_playbook_html(data: PlaybookData) -> str:

<h2>Top strategies</h2>
<table>
<thead><tr>
<th>Objective</th><th>Strategy</th><th>Expiry</th><th>POP (RN)</th>
<th>EV</th><th>ES(95%)</th><th>Max loss</th><th>Breakevens</th><th>Liquidity</th>
</tr></thead>
<thead><tr>{header_cells}</tr></thead>
<tbody>
{_strategy_rows_html(data.strategies)}
</tbody>
</table>

<h2>How to read this table</h2>
<dl class="glossary">{glossary}</dl>

<h2>Model assumptions</h2>
<ul>{assumptions}</ul>

Expand Down
14 changes: 12 additions & 2 deletions pages/10_Watchlist_Screener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.")

Expand Down
4 changes: 3 additions & 1 deletion pages/11_Advanced_Models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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**."
Expand Down
6 changes: 6 additions & 0 deletions pages/12_Playbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions pages/1_Ticker_Overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion pages/2_Options_Chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import streamlit as st

from app_lib import (
column_help,
load_chain,
load_underlying_dict,
page_footer,
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion pages/3_IV_Surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import streamlit as st

from app_lib import (
column_help,
load_chain,
load_underlying_dict,
page_footer,
Expand Down Expand Up @@ -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]
Expand Down
38 changes: 33 additions & 5 deletions pages/4_Vol_Diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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.")

Expand All @@ -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(
Expand Down
Loading
Loading