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>
179 lines
9.3 KiB
Python
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()
|