diff --git a/docs/data_architecture.md b/docs/data_architecture.md index 5c9a3fb..3d1a72c 100644 --- a/docs/data_architecture.md +++ b/docs/data_architecture.md @@ -60,6 +60,11 @@ Parquet is used for dense, columnar storage of historical data, optimizing read 4. **`data_cache/options/{Symbol}_options_history.parquet`** - *Purpose*: Historical options data used for max pain and strike analysis. - *Schema*: `Date`, `Expiry`, `UnderlyingPrice`, `SimulatedStrike`, `IntrinsicValue_M`, `MaxPainStrike`, `ETF_Proxy`. + - Since cotmetrics 0.14.0 the snapshot is taken on the nearest open standard monthly expiry (the chain that carries the open interest); rows before 2026-09-18 are on the nearest weekly. + +5. **`data_cache/options/{Symbol}_options_strikes.parquet`** (since cotmetrics 0.14.0, 2026-09-18) + - *Purpose*: the same day's chain on its real strikes, calls and puts apart. `CallPayout_M + PutPayout_M` is the curve above evaluated at the strike; what this adds is the split and the ladder itself, neither of which the summed 200-point grid keeps. Days before it have no rows and nothing backfills them. Not yet read by any panel. + - *Schema*: `Date`, `Expiry`, `UnderlyingPrice`, `Strike` (curve units), `EtfStrike` (as listed), `CallOI`, `PutOI`, `CallPayout_M`, `PutPayout_M`, `ETF_Proxy`. **B. Metadata Store (SQLite Relational DB)** `data/cftc_database.db` serves as a lightweight relational store. diff --git a/src/components/plot_options.py b/src/components/plot_options.py index b7c5f88..c27c086 100644 --- a/src/components/plot_options.py +++ b/src/components/plot_options.py @@ -10,6 +10,30 @@ import viz_constants as vc +def max_pain_point(daily_df): + """The (strike, payout) pair to mark as max pain for one day's snapshot. + + The snapshot writer already snaps the curve's minimum to the nearest REAL strike + and stores it as `MaxPainStrike` (cotmetrics.options_data.calculate_intrinsic_curve), + but the stored curve itself is a 200-point grid over +/-20% of the underlying, so + the grid's argmin is a point up to half a grid step (~0.1% of price) off any strike + that exists. Read the stored strike and interpolate the payout there, so the star, + the dashed line, the delta arrow and the premium/discount history all sit on a + strike the chain actually has. Rows written before the column existed fall back to + the grid argmin, which is what every reader used until now. + """ + import numpy as np + import pandas as pd + + daily_df = daily_df.sort_values('SimulatedStrike') + strike = daily_df['MaxPainStrike'].iloc[0] if 'MaxPainStrike' in daily_df else np.nan + if pd.isna(strike): + min_idx = daily_df['IntrinsicValue_M'].idxmin() + return daily_df.loc[min_idx, 'SimulatedStrike'], daily_df.loc[min_idx, 'IntrinsicValue_M'] + payout = np.interp(strike, daily_df['SimulatedStrike'], daily_df['IntrinsicValue_M']) + return float(strike), float(payout) + + def get_max_pain_plot(fig, asset_name, row, col): """ Plots the Notional Intrinsic Value curves of all options across different days. @@ -84,10 +108,7 @@ def get_max_pain_plot(fig, asset_name, row, col): hovertemplate=f"Date: {date}
Strike: %{{x:,.2f}}
IV: $%{{y:,.1f}}M" ), row=row, col=col) - # Find minimum - min_idx = daily_df['IntrinsicValue_M'].idxmin() - min_strike = daily_df.loc[min_idx, 'SimulatedStrike'] - min_iv = daily_df.loc[min_idx, 'IntrinsicValue_M'] + min_strike, min_iv = max_pain_point(daily_df) # Highlight the minimum. Only the latest day's strike drives the rest of the # panel (the price line, both dashed lines and the delta arrow all read it), @@ -206,10 +227,7 @@ def get_max_pain_historical_plot(fig, asset_name, row, col, showlegend=True): expiry_str = str(daily_df['Expiry'].iloc[0])[:10] # Grab just the date part - # Find max pain (min IV) - min_idx = daily_df['IntrinsicValue_M'].idxmin() - min_strike = daily_df.loc[min_idx, 'SimulatedStrike'] - min_iv = daily_df.loc[min_idx, 'IntrinsicValue_M'] + min_strike, min_iv = max_pain_point(daily_df) # Calculate premium/discount % premium_pct = ((underlying - min_strike) / min_strike) * 100 diff --git a/src/components/plot_registry.py b/src/components/plot_registry.py index 6f5d9e2..59c92e5 100644 --- a/src/components/plot_registry.py +++ b/src/components/plot_registry.py @@ -321,7 +321,8 @@ def _hint(*lines): "Dashed lines: payout at the current price vs at max pain; the arrow is the gap.", "Reading: a large gap means the market is far from where writers want it to", "settle, so watch for price drifting toward the star into expiry.", - "Quoted on the ETF named in the title, which may be a proxy for the future.", + "Quoted on the ETF named in the title, which may be a proxy for the future,", + "on its nearest open standard monthly expiry.", ) MAX_PAIN_HISTORICAL_HINT = _hint( @@ -329,9 +330,10 @@ def _hint(*lines): "max-pain strike, in %.", "Line (right axis): delta IV, the extra payout at the current price versus", "at max pain. A large delta is pressure toward the strike into expiry.", - "Each snapshot uses the nearest expiry more than 3 days out, so the series", - "can change expiry as chains roll; a jump can be a roll artifact rather than", - "positioning drift. The title names only the latest expiry.", + "Each snapshot is taken on the nearest open standard monthly expiry (the", + "chain that carries the open interest), so the series rolls once a month;", + "a jump on a roll is an expiry change, not positioning drift. The title names", + "only the latest expiry. Snapshots before 2026-09-18 used the nearest weekly.", ) @@ -436,12 +438,14 @@ def apply_title_hints(fig, plot_ids): # default stack once its empty state was traced to a reader pinned at the # pre-move history path (fixed alongside cotmetrics#40) rather than a broken # producer. The premium/discount history stays opt-in for a different reason: -# each snapshot targets the nearest expiry more than 3 days out, so the bar -# series mixes expiries as they roll (several times a week on ETFs with weekly -# chains) while the title names only the latest one -- a day-over-day move can -# be a roll artifact rather than positioning drift. Until the series is pinned -# to a stable expiry, it is a panel the reader opts into, not one the default -# stack vouches for. Registry-level so the stack pages cannot disagree about it. +# the bar series mixes expiries as they roll while the title names only the +# latest one, so a day-over-day move can be a roll artifact rather than +# positioning drift. Since cotmetrics 0.14.0 each snapshot targets the nearest +# open standard monthly, so the roll is monthly rather than several times a +# week, but the stored history before 2026-09-18 is still on the weeklies and +# the series has yet to accumulate a stretch on one expiry. Until it has, it is +# a panel the reader opts into, not one the default stack vouches for. +# Registry-level so the stack pages cannot disagree about it. DEFAULT_OFF_PLOTS = frozenset({"max_pain_historical"}) BASIS_INVARIANT_NOTE = {s.id: s.invariant_note for s in _SPECS diff --git a/tests/test_max_pain_strike.py b/tests/test_max_pain_strike.py new file mode 100644 index 0000000..0fe456d --- /dev/null +++ b/tests/test_max_pain_strike.py @@ -0,0 +1,83 @@ +"""The max-pain star sits on the stored real strike, not the grid's argmin. + +The options snapshot stores two things that name the max-pain price: a 200-point +simulated curve, and `MaxPainStrike`, the curve's minimum snapped to a strike the +chain actually has. Both panels used to take the grid argmin and ignore the stored +strike, so the star could sit between two real strikes. These pin that the stored +strike wins, that its payout is read off the curve at that strike, and that a +snapshot written before the column existed still resolves (to the grid argmin). + +Store-free: the parquet is written to tmp_path and the history dir monkeypatched. +""" + +import cotmetrics.options_data as od +import numpy as np +import pandas as pd +from plotly.subplots import make_subplots + +import components.plot_options as po + + +def _snapshot(date, underlying=100.0, max_pain=96.3, n=200): + grid = np.linspace(underlying * 0.8, underlying * 1.2, n) + # A V-shaped payout with its floor at 96.5, a grid point the strike ladder does + # not have. The real chain's nearest strike is 96.3. + payout = np.abs(grid - 96.5) * 10 + 5 + return pd.DataFrame({ + "Date": date, "Expiry": "2026-10-16", "UnderlyingPrice": underlying, + "SimulatedStrike": grid, "IntrinsicValue_M": payout, + "MaxPainStrike": max_pain, "ETF_Proxy": "SPY", + }) + + +def test_point_reads_the_stored_strike_and_interpolates_its_payout(): + df = _snapshot("2026-09-17") + strike, payout = po.max_pain_point(df) + assert strike == 96.3 + assert payout == np.interp(96.3, df["SimulatedStrike"], df["IntrinsicValue_M"]) + # And it is not the grid argmin, which is the point this exists to fix. + assert strike != df.loc[df["IntrinsicValue_M"].idxmin(), "SimulatedStrike"] + + +def test_point_falls_back_to_the_grid_when_the_column_is_missing_or_nan(): + df = _snapshot("2026-09-17") + grid_min = df.loc[df["IntrinsicValue_M"].idxmin()] + expected = (grid_min["SimulatedStrike"], grid_min["IntrinsicValue_M"]) + assert po.max_pain_point(df.drop(columns="MaxPainStrike")) == expected + assert po.max_pain_point(df.assign(MaxPainStrike=np.nan)) == expected + + +class _Instrument: + symbol = "ES" + + +class _Indexer: + def get_instrument_from_name(self, name): + return _Instrument() + + +def _write_history(tmp_path, monkeypatch, frames): + pd.concat(frames).to_parquet(tmp_path / "ES_options_history.parquet") + monkeypatch.setattr(od, "options_history_dir", lambda: tmp_path) + monkeypatch.setattr(po, "get_indexer", lambda: _Indexer()) + + +def test_star_and_dashed_line_sit_on_the_stored_strike(tmp_path, monkeypatch): + _write_history(tmp_path, monkeypatch, + [_snapshot("2026-09-16", max_pain=97.1), _snapshot("2026-09-17")]) + fig = make_subplots(rows=1, cols=1) + fig = po.get_max_pain_plot(fig, "S&P 500", 1, 1) + stars = [t for t in fig.data if t.name == "Max Pain Strike"] + assert [t.x[0] for t in stars] == [97.1, 96.3] + latest = stars[-1] + assert latest.marker.symbol == "star" + hlines = [s for s in fig.layout.shapes if s.type == "line" and s.y0 == s.y1] + assert latest.y[0] in {s.y0 for s in hlines} + + +def test_history_panel_measures_premium_against_the_stored_strike(tmp_path, monkeypatch): + _write_history(tmp_path, monkeypatch, [_snapshot("2026-09-17", underlying=100.0)]) + fig = make_subplots(rows=1, cols=1, specs=[[{"secondary_y": True}]]) + fig = po.get_max_pain_historical_plot(fig, "S&P 500", 1, 1) + bars = next(t for t in fig.data if t.type == "bar") + assert bars.y[0] == (100.0 - 96.3) / 96.3 * 100