- 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>
228 lines
12 KiB
Python
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()
|