""" Daytime reversal of the overnight gap, as the daytime half of the overnight-only book: the overnight sleeve (research/lab/overnight_only.py) holds stocks from the close to the open, so the same capital is idle from 09:30 to 16:00. Does buying the S&P 500 names that gapped down the most, at 09:35, and selling them at the close earn enough to stack on it? PYTHONPATH=. uv run python -m research.lab.fetch_alpaca stocks # once: 5-min bars PYTHONPATH=. uv run python -m research.lab.daytime_reversal Background: Akbas, Boehmer, Jiang & Koch, "Overnight returns, daytime reversals, and future stock returns" (JFE 2022); Lou, Polk & Skouras (JFE 2019). In the overnight study the momentum sleeve's names lost 0.8 bp a day intraday. Rules (Alpaca 5-min SIP bars, adjusted; regular session): gap close of the 09:30-09:35 bar / previous session's last-bar close - 1 universe PIT S&P 500 members on the previous session (research panel), with both bars present entry the close of the 09:30 bar (09:35); exit at the session's last bar close (MOC proxy). Early-close sessions (Alpaca calendar) are skipped. weights equal, 1x long (and 1x short for long_short); flat overnight costs entry 2 bp (half spread at 09:35), exit 1 bp (closing auction); stress 5 bp each side candidates long_losers_10 the 10 lowest gaps, long long_losers_z_10 the 10 lowest gap / sd of the last 20 daily close-to-close returns, long long_short_10 long the 10 lowest, short the 10 highest gaps Stacked (reported and gated): the overnight sleeve's night return (night ending at the open of day t) compounded with the day-t candidate return: (1 + night) x (1 + day) - 1, on the same capital. Span 2016-02 .. 2026-09. All of it lies after the papers' samples end, but the overnight sleeve was seen before this rule was written. The rule is written to prereg.json before the run; the script refuses to overwrite results.json. Result (2026-10-09): STOP. No candidate passes any gate. candidate gross bp/day gross Sharpe net Sharpe halves stress stacked (night alone 1.27) long_losers_10 3.2 0.34 0.02 0.16 / -0.08 -0.70 0.86 long_losers_z_10 1.3 0.17 -0.22 0.02 / -0.40 -1.11 0.79 long_short_10 6.4 0.60 0.04 0.37 / -0.19 -1.28 0.83 - The equal-weight S&P 500 earns 1.5 bp/day from 09:35 to the close. The losers' excess is about 2 bp, and 3 bp of round-trip cost removes it. - The reversal is weaker in 2021-26 than in 2016-20. - Stacking on the overnight sleeve lowers the Sharpe (1.27 -> 0.79-0.86). The day leg adds variance with no mean: corr with the night leg is ~0, and the long-only legs have corr 0.66-0.69 with the market's day move. Interpretation: in S&P 500 names after 2016, the daytime reversal of the overnight gap is too small to pay for trading it at 09:35. The idle daytime capital stays idle. The daily summary cache (daily_from_5min.pkl) is reusable. """ from __future__ import annotations import json import os import numpy as np import pandas as pd from research.lab.factor_scan import RESULTS, load OUT = os.path.join(RESULTS, "daytime_reversal") BARS = os.path.join("data", "research", "intraday", "alpaca", "5Min") DAILY_CACHE = os.path.join("data", "research", "intraday", "alpaca", "daily_from_5min.pkl") CAL_CACHE = os.path.join("data", "research", "intraday", "alpaca", "calendar.json") OVERNIGHT = os.path.join(RESULTS, "overnight_only", "daily.pkl") SPAN = ("2016-02-01", "2026-09-24") HALVES = [("2016-02-01", "2020-12-31"), ("2021-01-01", "2026-09-24")] TOP = 10 ENTRY_COST, EXIT_COST, STRESS = 0.0002, 0.0001, 0.0005 PREREG = { "question": "Does buying the S&P 500's biggest overnight losers at 09:35 and selling at the close earn a " "net Sharpe that is worth stacking on the overnight sleeve's idle daytime capital?", "rules": "gap = 09:35 price / prior last-bar close - 1; PIT S&P 500; equal weight; entry 09:35 bar close, " "exit last bar close; early closes skipped; costs 2 bp in + 1 bp out, stress 5 + 5", "candidates": ["long_losers_10", "long_losers_z_10", "long_short_10"], "span": SPAN, "halves": HALVES, "gates": {"D1": "full-span net Sharpe >= 1.0", "D2": "net Sharpe >= 0.7 in both halves", "D3": "stress (5 bp a side) full-span Sharpe >= 0.5", "D4": "stacked night+day Sharpe >= overnight sleeve alone + 0.2 (2016-02..2026-09)"}, "decision": {"CONTINUE": "a candidate passes D1-D4 -> add the daytime leg to the paper test " "(overnight_paper.py) and measure 09:35 fills", "STOP": "otherwise; the daytime capital stays idle"}, "multiplicity": "3 candidates", "data_status": "post-sample for the papers; overnight sleeve inspected before; Alpaca SIP 2016+", } def calendar_early_closes() -> set[pd.Timestamp]: if not os.path.exists(CAL_CACHE): from overnight_paper import Alpaca cal = Alpaca().get("/calendar", start="2016-01-01", end="2026-12-31") with open(CAL_CACHE, "w") as fh: json.dump(cal, fh) cal = json.load(open(CAL_CACHE)) return {pd.Timestamp(c["date"]) for c in cal if c["close"] != "16:00"} def daily_from_bars() -> dict[str, pd.DataFrame]: """Per symbol and session: 09:30-bar open/close, last-bar close (cached).""" if os.path.exists(DAILY_CACHE): return pd.read_pickle(DAILY_CACHE) o930, c935, clast = {}, {}, {} files = sorted(f for f in os.listdir(BARS) if f.endswith(".pkl")) for i, f in enumerate(files, 1): df = pd.read_pickle(os.path.join(BARS, f)) day = df.index.tz_localize(None).normalize() first = df.groupby(day).head(1) ok = (first.index.hour == 9) & (first.index.minute == 30) first = first[ok] idx = first.index.tz_localize(None).normalize() s = f[:-4] o930[s] = pd.Series(first["o"].values.astype(float), index=idx) c935[s] = pd.Series(first["c"].values.astype(float), index=idx) clast[s] = df["c"].groupby(day).last().astype(float) if i % 400 == 0: print(f" daily summary {i}/{len(files)}", flush=True) out = {"o930": pd.DataFrame(o930), "c935": pd.DataFrame(c935), "clast": pd.DataFrame(clast)} pd.to_pickle(out, DAILY_CACHE) return out def describe(r: pd.Series) -> dict: r = r.dropna() eq = (1 + r).cumprod() yrs = len(r) / 252 return {"cagr": float(eq.iloc[-1] ** (1 / yrs) - 1), "vol": float(r.std() * np.sqrt(252)), "sharpe": float(r.mean() / r.std() * np.sqrt(252)), "mdd": float((eq / eq.cummax() - 1).min()), "mean_bp_per_day": float(r.mean() * 1e4)} 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) D = daily_from_bars() early = calendar_early_closes() p, _ = load("us") cols = sorted(set(D["c935"].columns) & set(p.close.columns)) days = D["c935"].index.union(D["clast"].index).sort_values() days = days[(days >= SPAN[0]) & (days <= SPAN[1])] c935 = D["c935"].reindex(index=days, columns=cols) clast_all = D["clast"].reindex(columns=cols).sort_index() prev_last = clast_all.shift(1).reindex(days) # previous session's last close (per symbol rows) clast = clast_all.reindex(days) mem_prev = p.member.reindex(columns=cols).shift(1).reindex(days).fillna(False).astype(bool) gap = (c935 / prev_last - 1).where(mem_prev & c935.notna() & clast.notna() & prev_last.notna()) gap = gap[~gap.index.isin(early)] day_ret = (clast / c935 - 1).reindex(gap.index) vol20 = p.close.reindex(columns=cols).pct_change(fill_method=None).rolling(20, min_periods=15).std() \ .shift(1).reindex(gap.index) z = gap / vol20 def pick(score, n, low=True): rk = score.rank(axis=1, ascending=low, method="first") return rk <= n legs = {} lo = pick(gap, TOP) hi = pick(gap, TOP, low=False) loz = pick(z.where(gap.notna()), TOP) gross = { "long_losers_10": day_ret.where(lo).mean(axis=1), "long_losers_z_10": day_ret.where(loz).mean(axis=1), "long_short_10": day_ret.where(lo).mean(axis=1) - day_ret.where(hi).mean(axis=1), } sides = {"long_losers_10": 1, "long_losers_z_10": 1, "long_short_10": 2} uni = day_ret.mean(axis=1) night = pd.read_pickle(OVERNIGHT)["sleeve_score"].reindex(gap.index) res = {"candidates": {}, "n_days": int(len(gap)), "early_closes_skipped": int(len(early))} daily = {} for k, g in gross.items(): net = g - sides[k] * (ENTRY_COST + EXIT_COST) stress = g - sides[k] * 2 * STRESS stacked = (1 + night.fillna(0.0)) * (1 + net.fillna(0.0)) - 1 daily[k] = net d = {"gross": describe(g), "net": describe(net), "stress": describe(stress), "halves": [describe(net.loc[a:b]) for a, b in HALVES], "stacked": describe(stacked), "night_alone": describe(night.fillna(0.0)), "corr_with_night": float(net.corr(night)), "corr_with_universe_day": float(net.corr(uni)), "yearly": {int(y): float(v) for y, v in ((1 + net).groupby(net.index.year).prod() - 1).items()}} res["candidates"][k] = d print(f"{k:17s} gross {d['gross']['mean_bp_per_day']:5.1f}bp/d sh {d['gross']['sharpe']:5.2f} | net cagr " f"{d['net']['cagr']*100:6.1f}% sh {d['net']['sharpe']:5.2f} mdd {d['net']['mdd']*100:6.1f}% | halves " f"{d['halves'][0]['sharpe']:5.2f} {d['halves'][1]['sharpe']:5.2f} | stress {d['stress']['sharpe']:5.2f} | " f"stacked sh {d['stacked']['sharpe']:5.2f} (night {d['night_alone']['sharpe']:.2f}) corr night " f"{d['corr_with_night']:.2f} mkt {d['corr_with_universe_day']:.2f}", flush=True) print(" yearly net %:", {y: round(v * 100, 1) for y, v in d["yearly"].items()}) res["universe_day"] = describe(uni) print(f"EW S&P 500 09:35->close: {res['universe_day']['mean_bp_per_day']:.1f} bp/d sh {res['universe_day']['sharpe']:.2f}") checks = {} for k, d in res["candidates"].items(): checks[k] = {"D1": d["net"]["sharpe"] >= 1.0, "D2": all(h["sharpe"] >= 0.7 for h in d["halves"]), "D3": d["stress"]["sharpe"] >= 0.5, "D4": d["stacked"]["sharpe"] >= d["night_alone"]["sharpe"] + 0.2} passing = [k for k, c in checks.items() if all(c.values())] decision = "CONTINUE" if passing else "STOP" res["verdict"] = {"checks": checks, "passing": passing, "decision": decision} print(f"checks {checks}\n-> {decision} {passing}") 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"daytime_reversal:{k}", **res["candidates"][k]["net"]} for k in gross], {"study": "daytime_reversal", "role": "preregistered quick check"}) if __name__ == "__main__": main()