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
5 changes: 5 additions & 0 deletions docs/data_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 26 additions & 8 deletions src/components/plot_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -84,10 +108,7 @@ def get_max_pain_plot(fig, asset_name, row, col):
hovertemplate=f"Date: {date}<br>Strike: %{{x:,.2f}}<br>IV: $%{{y:,.1f}}M<extra></extra>"
), 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),
Expand Down Expand Up @@ -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
Expand Down
24 changes: 14 additions & 10 deletions src/components/plot_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,17 +321,19 @@ 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(
"Bars: how far the underlying closed above (+) or below (-) that day's",
"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.",
)


Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/test_max_pain_strike.py
Original file line number Diff line number Diff line change
@@ -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
Loading