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