Files
quant-trading/research/lab/intraday_momentum.py
Gahow Wang 65b8db7a12 research: 50%-target search — conservative book, daily quick checks, Alpaca intraday
- managed_futures_book: three sleeves + managed futures (DBMF, tsmom24 proxy
  before 2019-05) cuts MaxDD -37% -> -27% but CAGR 16-19%, Sharpe unchanged
  (REJECT).
- small_cap_reversal / turn_of_month / short_vol: preregistered quick checks,
  all STOP (net of spread costs, post-publication decay, post-2013 VRP 0.37).
- fetch_alpaca: resume-safe Alpaca SIP intraday bars 2016+ (1-min ETFs,
  5-min liquid S&P 1500), keys from .env and never logged.
- intraday_momentum: the SPY noise-area strategy replicates (yearly corr
  1.00 with the paper) but SPY Sharpe 0.99, ~0 after publication, and only
  QQQ of five other ETFs works (STOP).
- orb_stocks_in_play: preregistered 5-min ORB replication (not yet run).

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

228 lines
12 KiB
Python

"""
Replication of Zarattini, Aziz & Barbon, "Beat the Market: An Effective
Intraday Momentum Strategy for S&P500 ETF (SPY)" (SSRN 4824172, first posted
2024-05), on Alpaca 1-minute SIP bars 2016-2026, then the same rules on five
other ETFs. Intraday only: flat at every close.
PYTHONPATH=. uv run python -m research.lab.fetch_alpaca etfs # once
PYTHONPATH=. uv run python -m research.lab.intraday_momentum
Rules (from the paper's text; "dynamic" sizing, current band + VWAP stop):
move(d, m) |close(d, m) / open(d, 09:30) - 1|, m = minute of day
sigma(d, m) mean of move over the previous 14 days at the same minute
bands UB = max(open_d, close_{d-1}) x (1 + sigma); LB = min(open_d, close_{d-1}) x (1 - sigma)
decisions only at 10:00, 10:30, ..., 15:30, on the close of the minute that ends there:
long if price > UB and > VWAP, short if price < LB and < VWAP, else flat
(the band + VWAP trailing stop), filled at the open of the next minute
exit at the day's last close (16:00, or the early close)
size shares = equity_{d-1} x min(4, 2% / sd of the last 14 daily returns) / open_d
costs $0.0035 commission + $0.001 slippage per share traded
The account's cash earns nothing here, so Sharpe is on the strategy's own
returns (it holds no overnight position).
Fidelity check: the paper's yearly returns (its Table 9, dynamic sizing) for
2016-2023 are a reference for the implementation, not for the edge.
The rule is written to prereg.json before the run; the script refuses to
overwrite results.json. 2016-2024-04 lies inside the paper's own sample
(in-sample for the authors); 2024-05 onward is after publication.
Result (2026-10-09): STOP. F1 and E2 pass; E1 and E3 fail.
- Fidelity: our SPY yearly returns 2016-23 match the paper's (corr 1.00,
mean |diff| 2.3 pp).
- SPY net 14.1% / Sharpe 0.99 / MaxDD -26%, halves 0.74 / 1.20.
After publication: Sharpe 0.06 (2025 -3.6%, 2026 YTD -11.1%).
- Same rules, untuned, on the other five ETFs:
- QQQ 21.5% / 1.42 (halves 1.26 / 1.57, post-publication 1.24).
- IWM 0.07, DIA 0.03, TLT -0.36, GLD 0.02.
- Equal-capital 6-ETF book: 5.2% / Sharpe 0.65.
Interpretation: the edge is not a general intraday-momentum property of
liquid ETFs. QQQ is one of six tries; read alone it is a hypothesis, and it
needs untouched data (QQQ before 2016) before any use.
"""
from __future__ import annotations
import json
import os
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS
OUT = os.path.join(RESULTS, "intraday_momentum")
BARS = os.path.join("data", "research", "intraday", "alpaca", "1Min")
ETFS = ["SPY", "QQQ", "IWM", "DIA", "TLT", "GLD"]
SPAN = ("2016-02-01", "2026-10-08")
HALVES = [("2016-02-01", "2020-12-31"), ("2021-01-01", "2026-10-08")]
POST_PUB = ("2024-05-01", "2026-10-08")
LOOK, VOL_TARGET, MAX_LEV, PER_SHARE = 14, 0.02, 4.0, 0.0045
CHECKS = list(range(30, 361, 30)) # minutes after 09:30: 10:00 .. 15:30
PAPER_YEARLY = {2016: -12.8, 2017: -6.9, 2018: 61.1, 2019: 6.9, 2020: 26.8, 2021: 34.8, 2022: 24.4, 2023: 37.2}
PREREG = {
"question": "Does the SPY noise-area intraday momentum strategy replicate on Alpaca data, survive "
"publication, and generalize to other ETFs?",
"rules": "paper text: 14-day noise area with gap adjustment, HH:00/HH:30 decisions, band+VWAP stop, "
"flat at close, size min(4, 2%/sd14), $0.0045/share",
"fidelity": "F1 corr of our SPY yearly returns with the paper's 2016-2023 >= 0.8 and mean |diff| <= 8 pp "
"(otherwise FIX the implementation; no edge verdict)",
"continue_if_all": [
"E1 SPY net Sharpe >= 1.0 over 2016-02..2026-10",
"E2 SPY net Sharpe >= 0 after publication (2024-05..2026-10)",
"E3 net Sharpe >= 0.5 over 2016-26 on at least 3 of QQQ IWM DIA TLT GLD (same rules, untuned)",
],
"reported": ["halves 2016-20 / 2021-26", "equal-capital 6-ETF book",
"book gate for paper trading: net Sharpe >= 1.5 in both halves"],
"decision": {"CONTINUE": "build the multi-ETF intraday book, stress costs, and paper-trade it on Alpaca",
"STOP": "drop ETF intraday momentum", "FIX": "implementation does not match the paper"},
}
def day_matrices(df: pd.DataFrame) -> dict:
"""(days x 390) minute matrices; columns are minutes after 09:30."""
idx = df.index
minute = (idx.hour * 60 + idx.minute - 570).to_numpy()
dates = idx.tz_localize(None).normalize()
days = pd.DatetimeIndex(sorted(set(dates)))
row = days.get_indexer(dates)
D = len(days)
M = {}
for k in ("o", "c", "v", "vw"):
a = np.full((D, 390), np.nan)
a[row, minute] = df[k].to_numpy(dtype=float)
M[k] = a
last = np.full(D, -1)
np.maximum.at(last, row, minute)
first_open = pd.Series(df["o"].to_numpy(dtype=float), index=row).groupby(level=0).first().to_numpy()
c = pd.DataFrame(M["c"]).ffill(axis=1).to_numpy()
cols = np.arange(390)[None, :]
c[cols > last[:, None]] = np.nan # nothing after an early close
o = np.where(np.isnan(M["o"]), np.c_[np.full(D, np.nan), c[:, :-1]], M["o"])
v = np.nan_to_num(M["v"])
pv = np.nan_to_num(M["vw"]) * v
with np.errstate(invalid="ignore", divide="ignore"):
vwap = np.cumsum(pv, axis=1) / np.cumsum(v, axis=1)
day_close = c[np.arange(D), last]
return {"days": days, "c": c, "o": o, "vwap": vwap, "open": first_open, "last": last, "close": day_close}
def backtest(M: dict) -> pd.DataFrame:
c, o, vwap, op, last, close = M["c"], M["o"], M["vwap"], M["open"], M["last"], M["close"]
D = len(op)
move = np.abs(c / op[:, None] - 1)
sigma = pd.DataFrame(move).rolling(LOOK, min_periods=10).mean().shift(1).to_numpy()
prev_close = np.r_[np.nan, close[:-1]]
ret_d = pd.Series(close / prev_close - 1)
sd = ret_d.rolling(LOOK, min_periods=LOOK).std().shift(1).to_numpy() # through d-1
lev = np.minimum(MAX_LEV, VOL_TARGET / sd)
hi = np.fmax(op, prev_close)
lo = np.fmin(op, prev_close)
gross, net, trades, inmkt = np.zeros(D), np.zeros(D), np.zeros(D), np.zeros(D)
for d in range(D):
if not np.isfinite(lev[d]) or not np.isfinite(hi[d]):
continue
pos, entry, pnl, traded = 0, 0.0, 0.0, 0.0
for t in CHECKS:
if t > last[d] or not np.isfinite(sigma[d, t - 1]):
break
p, s, vw = c[d, t - 1], sigma[d, t - 1], vwap[d, t - 1]
ub, lb = hi[d] * (1 + s), lo[d] * (1 - s)
new = 1 if (p > ub and p > vw) else (-1 if (p < lb and p < vw) else 0)
if new != pos:
px = o[d, t]
if pos != 0:
pnl += pos * (px - entry)
traded += abs(new - pos)
pos, entry = new, px
inmkt[d] = 1
if pos != 0:
pnl += pos * (close[d] - entry)
traded += abs(pos)
gross[d] = lev[d] * pnl / op[d]
net[d] = gross[d] - lev[d] * traded * PER_SHARE / op[d]
trades[d] = traded
return pd.DataFrame({"gross": gross, "net": net, "lev": lev, "traded": trades, "in_market": inmkt},
index=M["days"])
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) if yrs > 0 else float("nan"),
"vol": float(r.std() * np.sqrt(252)),
"sharpe": float(r.mean() / r.std() * np.sqrt(252)) if r.std() > 0 else float("nan"),
"mdd": float((eq / eq.cummax() - 1).min())}
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)
R, res = {}, {"etfs": {}}
for sym in ETFS:
bt = backtest(day_matrices(pd.read_pickle(os.path.join(BARS, f"{sym}.pkl"))))
R[sym] = bt
d = {"full": describe(bt["net"].loc[SPAN[0]:SPAN[1]]),
"gross": describe(bt["gross"].loc[SPAN[0]:SPAN[1]]),
"halves": [describe(bt["net"].loc[a:b]) for a, b in HALVES],
"post_pub": describe(bt["net"].loc[POST_PUB[0]:POST_PUB[1]]),
"days_in_market": float(bt["in_market"].loc[SPAN[0]:].mean()),
"avg_lev": float(bt["lev"].loc[SPAN[0]:].mean()),
"shares_traded_per_day_x_lev": float((bt["traded"] * bt["lev"]).loc[SPAN[0]:].mean())}
yr = (1 + bt["net"].loc[SPAN[0]:SPAN[1]]).groupby(bt.loc[SPAN[0]:SPAN[1]].index.year).prod() - 1
d["yearly"] = {int(k): float(v) for k, v in yr.items()}
res["etfs"][sym] = d
f = d["full"]
print(f"{sym:4s} net cagr {f['cagr']*100:5.1f}% vol {f['vol']*100:4.1f}% sh {f['sharpe']:5.2f} "
f"mdd {f['mdd']*100:6.1f}% | gross sh {d['gross']['sharpe']:5.2f} | halves "
f"{d['halves'][0]['sharpe']:5.2f} {d['halves'][1]['sharpe']:5.2f} | post-pub sh "
f"{d['post_pub']['sharpe']:5.2f} cagr {d['post_pub']['cagr']*100:5.1f}% | in mkt "
f"{d['days_in_market']:.2f} lev {d['avg_lev']:.2f}", flush=True)
print(" yearly %:", {k: round(v * 100, 1) for k, v in d["yearly"].items()})
ours = pd.Series({y: res["etfs"]["SPY"]["yearly"].get(y, np.nan) * 100 for y in PAPER_YEARLY})
paper = pd.Series(PAPER_YEARLY)
fid = {"corr": float(ours.corr(paper)), "mean_abs_diff_pp": float((ours - paper).abs().mean()),
"ours": ours.round(1).to_dict(), "paper": PAPER_YEARLY}
res["fidelity"] = fid
print(f"fidelity vs paper 2016-23: corr {fid['corr']:.2f}, mean |diff| {fid['mean_abs_diff_pp']:.1f} pp")
book = pd.DataFrame({s: R[s]["net"] for s in ETFS}).loc[SPAN[0]:SPAN[1]].fillna(0.0).mean(axis=1)
res["book_equal6"] = {"full": describe(book), "halves": [describe(book.loc[a:b]) for a, b in HALVES],
"post_pub": describe(book.loc[POST_PUB[0]:])}
corr = pd.DataFrame({s: R[s]["net"] for s in ETFS}).loc[SPAN[0]:].corr().round(2)
res["daily_corr"] = corr.to_dict()
b = res["book_equal6"]
print(f"book (1/6 each) net cagr {b['full']['cagr']*100:5.1f}% sh {b['full']['sharpe']:5.2f} mdd "
f"{b['full']['mdd']*100:6.1f}% halves {b['halves'][0]['sharpe']:5.2f} {b['halves'][1]['sharpe']:5.2f} "
f"post-pub {b['post_pub']['sharpe']:5.2f}")
print("daily corr:\n", corr.to_string())
spy = res["etfs"]["SPY"]
others = [s for s in ETFS if s != "SPY" and res["etfs"][s]["full"]["sharpe"] >= 0.5]
f1 = fid["corr"] >= 0.8 and fid["mean_abs_diff_pp"] <= 8
checks = {"F1": f1, "E1": spy["full"]["sharpe"] >= 1.0, "E2": spy["post_pub"]["sharpe"] >= 0,
"E3": len(others) >= 3}
decision = "FIX" if not f1 else ("CONTINUE" if all(checks.values()) else "STOP")
res["verdict"] = {"checks": checks, "others_passing": others, "decision": decision,
"book_paper_trading_gate": all(h["sharpe"] >= 1.5 for h in b["halves"])}
print(f"checks {checks} others {others} -> {decision}; book gate {res['verdict']['book_paper_trading_gate']}")
with open(res_path, "w") as fh:
json.dump(res, fh, indent=2)
pd.concat({s: R[s] for s in ETFS}, axis=1).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"intraday_momentum:{s}", **res["etfs"][s]["full"]} for s in ETFS],
{"study": "intraday_momentum", "role": "preregistered replication"})
if __name__ == "__main__":
main()