research: daytime reversal of the overnight gap on S&P 500 (STOP)

Buying the biggest overnight losers at 09:35 and selling at the close earns
1-6 bp a day gross on 2016-26 Alpaca 5-min bars; net Sharpe ~0, and stacking
it on the overnight sleeve lowers the Sharpe (1.27 -> ~0.8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 22:07:26 -07:00
parent 4899bb9702
commit 6b24bedcbd
2 changed files with 226 additions and 0 deletions

View File

@@ -102,6 +102,9 @@ uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR
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.overnight_book # three-sleeve book with the overnight-only momentum sleeve, leverage for 50%/yr (preregistered; CONTINUE 2026-10-09: Sharpe 0.98 -> 1.38, MaxDD -37% -> -17% at 1 bp/side; 4 bp/side erases it)
PYTHONPATH=. uv run python -m research.lab.overnight_book_mf [--no-proxy] # the overnight books + a DBMF/CTA sleeve, levered to 50%/yr (preregistered; KEEP 2026-10-09: levered MaxDD unchanged, no CTA sleeve)
PYTHONPATH=. uv run python -m research.lab.daytime_reversal # buy the S&P 500's biggest overnight losers at 09:35, sell at the close (preregistered; STOP 2026-10-09: gross 1-6 bp/day, net Sharpe ~0)
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)
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.fetch_shortvolume # FINRA REG SHO daily short volume (~8y archive, resume-safe)
PYTHONPATH=. uv run python -m research.lab.short_volume [us|midsmall|all] # SVR (short-volume ratio) IC / quintile scan on PIT panels

View File

@@ -0,0 +1,223 @@
"""
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()