research: option-volume signals on OPRA bars 2024-03..2026-09 (STOP)

Johnson-So O/S has the opposite sign (Q5-Q1 -11.4%/yr, t -1.63) and
correlates -0.65 with the momentum score; unsigned put/call is flat
(t 0.42). Neither gated signal passes; keep collecting, retest at ~5 years.

The data handling was fixed after a coverage check of the panel, before any
return was computed: stock volume on every day from stock_raw, and a day
without bars counts as 0 contracts only inside a name's bar span (outside it
the options traded under an older ticker or the root's history starts later).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:31:54 -07:00
parent 251014d3b2
commit a605d68ca8
2 changed files with 64 additions and 10 deletions

View File

@@ -113,7 +113,7 @@ PYTHONPATH=. uv run --with numba python -m research.lab.svr_combo # low_svr2
PYTHONPATH=. uv run python -m research.lab.fetch_options snapshot|status # daily S&P 500 option chains after the close: indicative quotes (forward-only from 2026-10-12) + OPRA daily bars + print-vs-quote quality sample; data/options/us/; timer quant-trading-options PYTHONPATH=. uv run python -m research.lab.fetch_options snapshot|status # daily S&P 500 option chains after the close: indicative quotes (forward-only from 2026-10-12) + OPRA daily bars + print-vs-quote quality sample; data/options/us/; timer quant-trading-options
PYTHONPATH=. uv run python -m research.lab.fetch_options backfill # OPRA daily option bars since 2024-02 for every S&P 500 member since then (resumable, ~2-3 h; pauses 16:10-16:55 NY); then builds volume_panel.csv.gz PYTHONPATH=. uv run python -m research.lab.fetch_options backfill # OPRA daily option bars since 2024-02 for every S&P 500 member since then (resumable, ~2-3 h; pauses 16:10-16:55 NY); then builds volume_panel.csv.gz
PYTHONPATH=. uv run python -m research.lab.fetch_options volume-panel # date x underlying option volume (all 0-100 DTE strikes and the 5-100 DTE / 0.7-1.3 band), os_volume, pc_volume PYTHONPATH=. uv run python -m research.lab.fetch_options volume-panel # date x underlying option volume (all 0-100 DTE strikes and the 5-100 DTE / 0.7-1.3 band), os_volume, pc_volume
PYTHONPATH=. uv run --with numba python -m research.lab.option_volume # Johnson-So O/S and put/call volume on OPRA bars 2024-03..2026-09 (preregistered before the backfill finished; gate |t| >= 3) PYTHONPATH=. uv run --with numba python -m research.lab.option_volume # Johnson-So O/S and put/call volume on OPRA bars 2024-03..2026-09 (preregistered; STOP 2026-10-12: O/S sign reversed, t -1.6, corr -0.65 with momentum; put/call t 0.4)
uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs) uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs)
uv run python -m research.lab.fetch_us_fundamentals --universe small # the same for S&P 600 (IJR) uv run python -m research.lab.fetch_us_fundamentals --universe small # the same for S&P 600 (IJR)
PYTHONPATH=. uv run python -m research.lab.midcap_sleeve [--only h1..h4] [--universe small] # S&P 400 sleeves vs IJH; S&P 600 replication vs IJR (all REJECT 2026-10-08) PYTHONPATH=. uv run python -m research.lab.midcap_sleeve [--only h1..h4] [--universe small] # S&P 400 sleeves vs IJH; S&P 600 replication vs IJR (all REJECT 2026-10-08)

View File

@@ -32,6 +32,13 @@ Test (PIT S&P 500 research panel, members only, >= 50 names a day):
the spanning t on it, as in research/lab/factor_census.py. the spanning t on it, as in research/lab/factor_census.py.
Window: 2024-03-01 .. the panel's last date (2026-09-24), about 2.5 years. Window: 2024-03-01 .. the panel's last date (2026-09-24), about 2.5 years.
Halves are split at 2025-06-01. Halves are split at 2025-06-01.
Data handling (set after a coverage check of the panel, before any return was
computed): stock volume comes from stock_raw.csv.gz on every day. A day
without bars counts as 0 contracts inside the name's first..last bar date
(thin names such as NWS, REG, UDR: 0.6% of stock-days). It is missing
outside that span, where the options traded under an older ticker or Alpaca's
history for the root starts later (BNY, XYZ, BRK.B, BF.B ...), and on
2024-02-02, which has no bars at all.
Gate per gated signal (fixed before the backfill finished; the factor census Gate per gated signal (fixed before the backfill finished; the factor census
asks |t| >= 3 of a new factor): asks |t| >= 3 of a new factor):
@@ -47,6 +54,33 @@ Decision:
This is the first look at these data. Nothing in the window was used before. This is the first look at these data. Nothing in the window was used before.
The rule is written to prereg.json before the run; the script refuses to The rule is written to prereg.json before the run; the script refuses to
overwrite results.json. overwrite results.json.
Result (2026-10-12): STOP. low_os_5 fails V1, V2 and V3. low_pc_5 fails V1
and V3.
2024-03..2026-09, h=5 IC (t) Q5-Q1/yr (t) halves %/yr top-20 alpha vs EW (t) corr house
low_os_5 (gated) -0.013 (-1.32) -11.4% (-1.63) -1.1 / -21.3 -5.4% (-1.40) -0.65
low_pc_5 (gated) +0.000 (+0.07) +1.2% (+0.42) +1.0 / +1.4 -5.4% (-1.40) -0.08
low_os_21 -0.018 (-1.70) -11.7% (-1.68) -4.0 / -19.1 -2.7% (-0.71) -0.65
low_os_band_5 -0.013 (-1.36) -10.1% (-1.52) -1.2 / -18.6 -5.0% (-1.24) -0.65
low_os_abn -0.003 (-0.60) -3.7% (-1.05) +2.1 / -7.7 -6.0% (-1.34) -0.46
call_shift -0.009 (-1.73) -2.5% (-0.87) -1.0 / -3.7 -7.0% (-1.95) -0.19
house_overnight_recovery +0.014 (+1.07) +8.0% (+0.91) -2.5 / +18.1 +31.0% (+1.90) 1
- O/S has the opposite sign to Johnson-So in this window. Names with high
option volume relative to stock volume did better, mostly after 2025-06
(-21%/yr in the second half). The O/S score correlates -0.65 with the
momentum score: high O/S marks recent winners with heavy call trading.
Its spanning t on the momentum spread is -1.28, so momentum already holds
what O/S carries. It is not a usable signal in either direction (|t| < 2).
- Unsigned put/call volume is flat (t 0.42). Without open-buy signing this
does not test Pan-Poteshman's result.
- One of the 24 reported rows reaches |t| >= 2: call_shift's top-20 alpha at
h=21 (-7.8%, t -2.34). That is within what chance gives.
- Boundary: 2.5 years in one regime (AI-led momentum). SNDK was fetched after
a batch-splitting fix for HTTP 504. CDAY (renamed DAY) and NVR (no listed
options) have no bars.
- Next: keep collecting the daily bars and retest at ~5 years (2029).
""" """
from __future__ import annotations from __future__ import annotations
@@ -66,6 +100,7 @@ from research.lab.us_sweep import run
OUT = os.path.join(RESULTS, "option_volume") OUT = os.path.join(RESULTS, "option_volume")
PANEL = os.path.join("data", "options", "us", "volume_panel.csv.gz") PANEL = os.path.join("data", "options", "us", "volume_panel.csv.gz")
STOCK = os.path.join("data", "options", "us", "stock_raw.csv.gz")
START = "2024-03-01" START = "2024-03-01"
SPLIT = "2025-06-01" SPLIT = "2025-06-01"
GATED = ["low_os_5", "low_pc_5"] GATED = ["low_os_5", "low_pc_5"]
@@ -79,6 +114,10 @@ PREREG = {
"decision": {"CONTINUE": "preregister a combination with overnight_recovery_top10 and add the live signal", "decision": {"CONTINUE": "preregister a combination with overnight_recovery_top10 and add the live signal",
"STOP": "keep collecting; retest at ~5 years of history"}, "STOP": "keep collecting; retest at ~5 years of history"},
"data_status": "first look; option bars backfilled 2026-10-12, not inspected before this rule", "data_status": "first look; option bars backfilled 2026-10-12, not inspected before this rule",
"data_handling": "fixed after a coverage check of the panel, before any return was computed: stock volume "
"from stock_raw on every day; a day without bars counts as 0 contracts inside the name's "
"first..last bar date, and is missing outside it (older tickers, late root history) and on "
"2024-02-02 (no bars)",
} }
@@ -89,15 +128,30 @@ def wide(P: pd.DataFrame, col: str, dates, cols) -> pd.DataFrame:
return w.reindex(index=dates, columns=cols) return w.reindex(index=dates, columns=cols)
def signals(p, P: pd.DataFrame) -> dict[str, pd.DataFrame]: def coverage(P: pd.DataFrame, dates, cols) -> pd.DataFrame:
"""True where a missing bar row means no option traded: inside the name's
first..last bar date and on a date with any bars. Outside it the volume is
unknown (options listed under an older ticker, e.g. XYZ before 2025-01 as SQ;
Alpaca's history for the root starts later, e.g. BRK.B 2024-08; 2024-02-02
has no bars at all)."""
span = P.groupby("underlying")["date"].agg(["min", "max"])
span.index = [c.replace(".", "-") for c in span.index]
lo = pd.to_datetime(span["min"]).reindex(cols).values[None, :]
hi = pd.to_datetime(span["max"]).reindex(cols).values[None, :]
d = dates.values[:, None]
has_day = np.asarray(dates.isin(pd.to_datetime(P["date"].unique())))[:, None]
return pd.DataFrame((d >= lo) & (d <= hi) & has_day, index=dates, columns=cols)
def signals(p, P: pd.DataFrame, stock: pd.DataFrame) -> dict[str, pd.DataFrame]:
dates, cols = p.dates, p.close.columns dates, cols = p.dates, p.close.columns
have = wide(P, "volume", dates, cols) sv = wide(stock.rename(columns={"symbol": "underlying"}), "volume", dates, cols)
has_options = have.notna() # stock-volume row from the options store exists sv = sv.where(sv > 0) # unadjusted SIP volume on every stock day
C = wide(P, "v_C", dates, cols).where(has_options).fillna(0).where(has_options) cover = coverage(P, dates, cols)
Pv = wide(P, "v_P", dates, cols).where(has_options).fillna(0).where(has_options) C = wide(P, "v_C", dates, cols).fillna(0).where(cover)
Cb = wide(P, "v_band_C", dates, cols).where(has_options).fillna(0).where(has_options) Pv = wide(P, "v_P", dates, cols).fillna(0).where(cover)
Pb = wide(P, "v_band_P", dates, cols).where(has_options).fillna(0).where(has_options) Cb = wide(P, "v_band_C", dates, cols).fillna(0).where(cover)
sv = have.where(have > 0) Pb = wide(P, "v_band_P", dates, cols).fillna(0).where(cover)
s = lambda x, n: x.rolling(n, min_periods=n).sum() # noqa: E731 s = lambda x, n: x.rolling(n, min_periods=n).sum() # noqa: E731
os5 = s(C + Pv, 5) * 100 / s(sv, 5) os5 = s(C + Pv, 5) * 100 / s(sv, 5)
os21 = s(C + Pv, 21) * 100 / s(sv, 21) os21 = s(C + Pv, 21) * 100 / s(sv, 21)
@@ -127,7 +181,7 @@ def main() -> None:
p, _ = load("us") p, _ = load("us")
P = pd.read_csv(PANEL) P = pd.read_csv(PANEL)
S = signals(p, P) S = signals(p, P, pd.read_csv(STOCK))
end = str(p.dates[-1].date()) end = str(p.dates[-1].date())
m = p.member m = p.member
house = (pct_rank(FACTORS["overnight_mom"](p), m) + pct_rank(FACTORS["rec_126"](p), m)) / 2 house = (pct_rank(FACTORS["overnight_mom"](p), m) + pct_rank(FACTORS["rec_126"](p), m)) / 2