""" 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()