Files
quant-trading/research/lab/overnight_midsmall.py
Gahow Wang 04e2c5c3be research: commit the 2026-10-07/08 studies left in the working tree, overnight mid/small (STOP)
Studies run by earlier sessions and documented in CLAUDE.md, but never
committed: FINRA short volume (SVR) and the research-only svr_momentum veto,
SEC fails-to-deliver, 13D activists and the 13D event sleeve, 13F
institutional flow, GDELT news volume, S&P 400/600 Form 4 insiders,
union_shield, shield_lab; plus the mid/small insider and Tiingo fetcher
changes and midsmall_rebuild.

overnight_midsmall: the overnight-only momentum book on S&P 400/600 earns
0.93 net Sharpe at 3 bp a side (2019-26) with corr 0.83 to the S&P 500 book
(STOP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:20:43 -07:00

179 lines
9.3 KiB
Python

"""
The overnight-only momentum book on S&P 400/600 names: the same rule as
research/lab/overnight_only.py (top 10 by the live sleeve's score, bought at
the close, sold at the next open, flat in the day), on a second universe. Is
it a second, partly independent stream next to the S&P 500 one?
PYTHONPATH=. uv run python -m research.lab.overnight_midsmall
Rules (untuned copy of overnight_only's sleeve_score candidate):
universe PIT S&P 400 or S&P 600 members on t-2 (research/lab/panel_mid.py,
midsmall), close > $5 and 60-day median dollar volume >= $20m
(auction capacity), both read at t-2
score overnight momentum (12-1) + distance from the 126-day low,
percentile blend among the eligible names, read at the close of t-2
book top 10, equal weight, held close t-1 -> open t, rebuilt nightly
costs both auctions every night: c per side, base c = 3 bp (mid/small
auctions), stress c = 6 bp; 1 and 2 bp reported
cash earns nothing; Sharpe = mean / sd of the book's own returns
Windows: membership coverage in the panel is >= 85% only from 2019 (Yahoo and
Tiingo miss delisted mid caps before), so the primary window is 2019-01-02 ..
2026-09-24 (halves 2019-2022 / 2023-2026). 2010-2018 is reported, survivor-biased.
Combination (gate S4): the S&P 500 overnight sleeve (overnight_only daily.pkl,
1 bp) and this book at 3 bp, half the capital each, rebalanced daily.
Fidelity (S5): Yahoo/Tiingo opens against the Alpaca 09:30 bar
(overnight_only.fidelity), 2016+.
Every period of these panels was examined in earlier mid/small studies (all
REJECT/FAIL for all-day holding); the overnight split was not. The S&P 500
overnight result was seen before this rule. The rule is written to
prereg.json before the run; the script refuses to overwrite results.json.
Result (2026-10-09): STOP. S2 and S5 pass; S1, S3 and S4 fail.
sleeve_score, primary 2019-01..2026-09
gross 15.3 bp/day
net Sharpe by cost per side: 1 bp 1.32, 2 bp 1.12, 3 bp 0.93, 6 bp 0.33
at 3 bp: 22.5%/yr, MaxDD -33%, halves 0.78 / 1.09
2010-18 reference (survivor-biased): 0.65
overnight_12_1 (reported): 0.76 at 3 bp
Fidelity: 1,254 names, corr 0.975 on all name-days, 0.996 on selected;
mean diff -0.2 bp.
- The gross overnight edge is about the S&P 500's (15.3 vs 13.5 bp/day),
but mid/small auctions cost more.
- It is not a second stream: daily corr with the S&P 500 overnight sleeve
is 0.83, and a 50/50 combination lowers the Sharpe (1.17 -> 1.09).
Interpretation: overnight momentum is one common factor across size
buckets, not a per-universe edge. More names do not diversify it; cheaper
auctions (the S&P 500) are where to hold it.
"""
from __future__ import annotations
import json
import os
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS
from research.lab.factors import overnight_mom, rec_126
from research.lab.overnight_only import describe, fidelity, legs, nightly, top_mask
from research.lab.panel_mid import load_us_mid
from research.lab.us_sweep import _blend
OUT = os.path.join(RESULTS, "overnight_midsmall")
LARGE = os.path.join(RESULTS, "overnight_only", "daily.pkl")
PRIMARY = ("2019-01-02", "2026-09-24")
HALVES = [("2019-01-02", "2022-12-30"), ("2023-01-03", "2026-09-24")]
REFERENCE = ("2010-01-04", "2018-12-31")
TOP_N = 10
COST_BASE, COST_STRESS = 0.0003, 0.0006
MIN_PRICE, MIN_DV = 5.0, 20e6
PREREG = {
"question": "Does the overnight-only momentum book (S&P 500 rule, untuned) earn a net Sharpe >= 1.0 on "
"S&P 400/600 names at mid/small auction costs, and add to the S&P 500 overnight sleeve?",
"rules": "PIT S&P 400/600 at t-2, close > $5, 60d median $ volume >= $20m; sleeve score at t-2; top 10 EW; "
"close t-1 -> open t; 2 x c per night, c = 3 bp base / 6 bp stress",
"primary": PRIMARY, "halves": HALVES, "reference_survivor_biased": REFERENCE,
"gates": {"S1": "primary net Sharpe (3 bp) >= 1.0",
"S2": "net Sharpe (3 bp) >= 0.7 in both primary halves",
"S3": "primary stress Sharpe (6 bp) >= 0.5",
"S4": "primary: daily corr with the S&P 500 overnight sleeve <= 0.6 and the 50/50 combination's "
"Sharpe >= the S&P 500 sleeve's + 0.1",
"S5": "open fidelity vs Alpaca (2016+): corr >= 0.9 on all name-days, |mean diff| <= 3 bp on "
"selected name-days"},
"decision": {"CONTINUE": "S1-S5 pass -> add a mid/small overnight leg to the paper test",
"DATA": "S5 fails -> rebuild from Alpaca opens first",
"STOP": "otherwise"},
"reported": "overnight_12_1 score, costs 1/2 bp, 2010-2018 reference, yearly returns, auction $ capacity",
"data_status": "panels examined in earlier mid/small studies; overnight split new; S&P 500 overnight seen",
}
def main() -> None:
os.makedirs(OUT, exist_ok=True)
res_path = os.path.join(OUT, "results.json")
if os.path.exists(res_path):
raise SystemExit(f"{res_path} exists; this study does not overwrite results")
with open(os.path.join(OUT, "prereg.json"), "w") as fh:
json.dump(PREREG, fh, indent=2)
p = load_us_mid("midsmall")
dv = (p.close * p.volume).rolling(60, min_periods=40).median()
elig = p.member & (p.close > MIN_PRICE) & (dv >= MIN_DV)
on, _ = legs(p)
scores = {
"sleeve_score": _blend(*[f.where(elig).rank(axis=1, pct=True)
for f in (overnight_mom(p), rec_126(p))]).where(elig),
"overnight_12_1": overnight_mom(p).where(elig),
}
large = pd.read_pickle(LARGE)["sleeve_score"]
res, sels, daily = {"candidates": {}}, {}, {}
for name, sc in scores.items():
sel = top_mask(sc.shift(2), TOP_N)
sels[name] = sel
gross = nightly(sel, on)
d = {}
for c in (0.0001, 0.0002, COST_BASE, COST_STRESS):
net = (gross - 2 * c).loc[REFERENCE[0]:PRIMARY[1]]
d[f"{c*1e4:.0f}bp"] = {"primary": describe(net.loc[PRIMARY[0]:PRIMARY[1]]),
"halves": [describe(net.loc[a:b]) for a, b in HALVES],
"reference": describe(net.loc[REFERENCE[0]:REFERENCE[1]])}
base = (gross - 2 * COST_BASE).loc[PRIMARY[0]:PRIMARY[1]]
daily[name] = gross - 2 * COST_BASE
L = large.reindex(base.index)
combo = 0.5 * base.fillna(0.0) + 0.5 * L.fillna(0.0)
d["corr_with_large"] = float(base.corr(L))
d["large_primary"] = describe(L.dropna())
d["combo_primary"] = describe(combo)
d["gross_primary"] = describe(gross.loc[PRIMARY[0]:PRIMARY[1]])
d["yearly_3bp"] = {int(y): float(v) for y, v in
((1 + daily[name].loc[REFERENCE[0]:PRIMARY[1]]).groupby(
daily[name].loc[REFERENCE[0]:PRIMARY[1]].index.year).prod() - 1).items()}
held_dv = dv.shift(2).where(sel).loc[PRIMARY[0]:PRIMARY[1]]
d["selected_median_dollar_volume"] = float(np.nanmedian(held_dv.values))
d["eligible_per_day"] = float(elig.loc[PRIMARY[0]:PRIMARY[1]].sum(axis=1).mean())
res["candidates"][name] = d
b = d["3bp"]
print(f"{name:15s} gross {d['gross_primary']['mean_bp_per_day']:5.1f}bp/d | 3bp: cagr "
f"{b['primary']['cagr']*100:6.1f}% sh {b['primary']['sharpe']:5.2f} mdd {b['primary']['mdd']*100:6.1f}% "
f"halves {b['halves'][0]['sharpe']:5.2f} {b['halves'][1]['sharpe']:5.2f} | ref 2010-18 sh "
f"{b['reference']['sharpe']:5.2f} | 1bp {d['1bp']['primary']['sharpe']:5.2f} 2bp "
f"{d['2bp']['primary']['sharpe']:5.2f} 6bp {d['6bp']['primary']['sharpe']:5.2f} | corr large "
f"{d['corr_with_large']:.2f} combo sh {d['combo_primary']['sharpe']:.2f} (large "
f"{d['large_primary']['sharpe']:.2f}) | eligible {d['eligible_per_day']:.0f} med $vol "
f"{d['selected_median_dollar_volume']/1e6:.0f}m", flush=True)
print(" yearly net 3bp %:", {y: round(v * 100, 1) for y, v in d["yearly_3bp"].items()})
fid = fidelity(p, sels)
res["fidelity"] = fid
print("fidelity:", {k: (round(v, 3) if isinstance(v, float) else v) for k, v in fid.items()})
s5 = (fid.get("symbols", 0) > 0 and fid["corr_all"] >= 0.9
and all(abs(fid[f"{c}_mean_diff_bp"]) <= 3 for c in scores if fid.get(f"{c}_name_days", 0) > 0))
d = res["candidates"]["sleeve_score"]
checks = {"S1": d["3bp"]["primary"]["sharpe"] >= 1.0,
"S2": all(h["sharpe"] >= 0.7 for h in d["3bp"]["halves"]),
"S3": d["6bp"]["primary"]["sharpe"] >= 0.5,
"S4": d["corr_with_large"] <= 0.6 and d["combo_primary"]["sharpe"] >= d["large_primary"]["sharpe"] + 0.1,
"S5": s5}
decision = "DATA" if not s5 else ("CONTINUE" if all(checks.values()) else "STOP")
res["verdict"] = {"checks": checks, "decision": decision}
print(f"checks {checks} -> {decision}")
with open(res_path, "w") as fh:
json.dump(res, fh, indent=2)
pd.DataFrame(daily).to_pickle(os.path.join(OUT, "daily.pkl"))
from research.lab.trials import append_trials
append_trials(os.path.join(RESULTS, "trials_us.jsonl"),
[{"name": f"overnight_midsmall:{n}", **res["candidates"][n]["3bp"]["primary"]} for n in scores],
{"study": "overnight_midsmall", "role": "preregistered quick check"})
if __name__ == "__main__":
main()