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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user