""" Replication of Zarattini, Barbon & Aziz, "A Profitable Day Trading Strategy For The U.S. Equity Market" (SSRN 4729284, 2024-02): the 5-minute opening range breakout on the 20 "stocks in play" of the day, on Alpaca 5-minute SIP bars 2016-2026. Intraday only: flat at every close. PYTHONPATH=. uv run python -m research.lab.fetch_alpaca stocks # once (hours) PYTHONPATH=. uv run python -m research.lab.orb_stocks_in_play Rules (from the paper's text): filters open > $5; mean daily volume of the previous 14 days >= 1,000,000 shares; 14-day ATR > $0.50; relative volume >= 1, where relative volume = volume of the 09:30-09:35 bar / its mean over the previous 14 days; then the 20 highest relative volumes direction long if the first 5-minute bar closes above its open, short if below, none for a doji entry stop order at the opening-range high (long) / low (short) from 09:35 stop loss 10% of the 14-day ATR from the fill exit stop, or the day's last close size loss at the stop = 1% of equity, each position's notional capped at 4 x equity / 20 (the paper's 4x leverage limit) costs $0.0035 per share per side (the paper); stress: + 5 bp per side Deviations, fixed before the run: - Universe: PIT S&P 500/400/600 members on the day, not the paper's ~7,000 US stocks. Most "in play" small caps outside the S&P 1500 are missing. - 5-minute bars: an entry at a gap-through bar fills at that bar's open. If the entry bar also reaches the stop, the trade is counted as stopped out (conservative). A stop gapped through fills at the next bar's open. - Daily volume, ATR and closes come from the regular-session bars. The rule is written to prereg.json before the run; the script refuses to overwrite results.json. 2016-2023 is the paper's own sample; 2024-01 onward is after it. The fetch was pre-filtered to names that passed the filters on at least one member day (fetch_alpaca.universe, liquid=True). The daily filters above still apply. Result (2026-10-09): STOP. The conservative bound fails all gates; the optimistic bound fails O3 (stress). - Data: 1,686 files, 356,908 eligible name-days, 42,807 trades. - Conservative (a stop inside the entry bar counts as hit): -70.5%/yr, Sharpe -7.5, win rate 8%. - Optimistic (no stop inside the entry bar): - Commission only: 71.5%/yr, Sharpe 2.14, MaxDD -29%. Paper-span Sharpe 2.19, 2024+ 1.98, halves 2.32 / 1.97. - +5 bp per side: Sharpe -0.86. - A 10%-ATR stop sits inside a typical 5-minute range, so bar resolution decides the sign. - Even the optimistic bound breaks even at only 3.6 bp of slippage per side on top of commission (1 bp: Sharpe 1.54; 2 bp: 0.94; 3 bp: 0.34), at 3.2x average gross leverage. - Stop-order entries on breakouts of stocks in play rarely fill within a few bp, so 1-minute bars (RESOLVE) would not change the decision. """ 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, "orb_stocks_in_play") BARS = os.path.join("data", "research", "intraday", "alpaca", "5Min") SPAN = ("2016-02-01", "2026-10-08") PAPER = ("2016-02-01", "2023-12-31") POST = ("2024-01-01", "2026-10-08") HALVES = [("2016-02-01", "2020-12-31"), ("2021-01-01", "2026-10-08")] TOP, RISK, MAX_LEV, ATR_STOP = 20, 0.01, 4.0, 0.10 COMMISSION, STRESS = 0.0035, 0.0005 PREREG = { "question": "Does the 5-minute ORB on the top-20 stocks in play replicate on the PIT S&P 1500 with " "5-minute bars, and does it hold after the paper's sample?", "rules": "paper text: price>5, avg vol14>=1M, ATR14>0.5, relvol>=1, top 20 relvol, OR direction, " "stop entry at OR high/low, stop 10% ATR, exit at close, risk 1%, notional cap 4x/20", "deviations": ["S&P 1500 PIT universe", "5-min bars, same-bar stop counted as hit"], "continue_if_all": [ "O1 net Sharpe >= 1.5 over 2016-02..2023-12 (the paper's sample)", "O2 net Sharpe >= 1.0 over 2024-01..2026-10 (after it)", "O3 net Sharpe >= 1.0 over 2016-26 with + 5 bp per side slippage", "O4 net Sharpe >= 1.0 in 2016-20 and in 2021-26", ], "bounds": "primary = conservative (a stop inside the entry bar counts as hit); optimistic = the stop is " "checked only from the bar after the entry. Both are run with the same gates.", "decision": {"CONTINUE": "conservative passes: widen to all US stocks with 1-min bars, then paper-trade", "RESOLVE": "only the optimistic bound passes: fetch 1-min bars for the ambiguous name-days " "and rerun (a new study)", "STOP": "the optimistic bound fails too: drop ORB on stocks in play"}, } def day_matrix(df: pd.DataFrame) -> dict: idx = df.index slot = ((idx.hour * 60 + idx.minute - 570) // 5).to_numpy() dates = idx.tz_localize(None).normalize() days = pd.DatetimeIndex(sorted(set(dates))) row = days.get_indexer(dates) out = {"days": days} for k in ("o", "h", "l", "c", "v"): a = np.full((len(days), 78), np.nan) a[row, slot] = df[k].to_numpy(dtype=float) out[k] = a return out def trades(df: pd.DataFrame, optimistic: bool = False) -> pd.DataFrame: """Per day: eligibility inputs and the trade's return per unit of notional.""" M = day_matrix(df) O, H, L, C, V = M["o"], M["h"], M["l"], M["c"], M["v"] D = len(M["days"]) has_or = np.isfinite(O[:, 0]) & np.isfinite(C[:, 0]) last_c = pd.DataFrame(C).ffill(axis=1).iloc[:, -1].to_numpy() d_open = O[:, 0] d_high, d_low = np.nanmax(H, axis=1), np.nanmin(L, axis=1) d_vol = np.nansum(V, axis=1) prev_c = np.r_[np.nan, last_c[:-1]] tr = np.fmax(d_high - d_low, np.fmax(np.abs(d_high - prev_c), np.abs(d_low - prev_c))) atr = pd.Series(tr).rolling(14, min_periods=14).mean().shift(1).to_numpy() avgvol = pd.Series(d_vol).rolling(14, min_periods=14).mean().shift(1).to_numpy() orv = np.where(has_or, V[:, 0], np.nan) relvol = orv / pd.Series(orv).rolling(14, min_periods=10).mean().shift(1).to_numpy() direction = np.sign(C[:, 0] - O[:, 0]) ret = np.full(D, np.nan) entry_px = np.full(D, np.nan) j = np.arange(78)[None, :] for sgn in (1, -1): rows = np.where((direction == sgn) & has_or & np.isfinite(atr))[0] if len(rows) == 0: continue lvl = H[rows, 0] if sgn == 1 else L[rows, 0] hh, ll, oo = H[rows], L[rows], O[rows] trig = (hh > lvl[:, None]) if sgn == 1 else (ll < lvl[:, None]) trig[:, 0] = False trig &= np.isfinite(hh) any_t = trig.any(axis=1) k = np.argmax(trig, axis=1) ok = np.where(any_t)[0] r_ = rows[ok] kk = k[ok] o_k = oo[ok, kk] ent = np.where(np.isfinite(o_k), np.fmax(lvl[ok], o_k) if sgn == 1 else np.fmin(lvl[ok], o_k), lvl[ok]) stop = ent - sgn * ATR_STOP * atr[r_] hit = (ll[ok] <= stop[:, None]) if sgn == 1 else (hh[ok] >= stop[:, None]) hit &= (j > kk[:, None]) if optimistic else (j >= kk[:, None]) any_h = hit.any(axis=1) h = np.argmax(hit, axis=1) o_h = oo[ok, h] gap_fill = np.where(np.isfinite(o_h), np.fmin(stop, o_h) if sgn == 1 else np.fmax(stop, o_h), stop) stop_fill = np.where(h == kk, stop, gap_fill) # same bar as the entry: at the stop exit_px = np.where(any_h, stop_fill, last_c[r_]) ret[r_] = sgn * (exit_px / ent - 1) entry_px[r_] = ent return pd.DataFrame({"open": d_open, "atr": atr, "avgvol": avgvol, "relvol": relvol, "dir": direction, "ret": ret, "entry": entry_px}, index=M["days"]) def members() -> pd.DataFrame: from research.lab.factor_scan import load from research.lab.panel_mid import load_us_mid a, _ = load("us") b = load_us_mid("midsmall") m = pd.concat([a.member, b.member], axis=1) return m.T.groupby(level=0).any().T.loc["2015-12-01":] def book(T: pd.DataFrame, extra: float) -> pd.DataFrame: """T: rows (date, symbol) of the day's selected names. Daily return on equity.""" T = T.copy() risk_notional = RISK * T["entry"] / (ATR_STOP * T["atr"]) # notional / equity at 1% risk T["w"] = np.minimum(risk_notional, MAX_LEV / TOP) T["cost"] = 2 * (COMMISSION / T["entry"] + extra) T["pnl"] = T["w"] * (T["ret"] - T["cost"]) g = T.groupby(level="date") return pd.DataFrame({"ret": g["pnl"].sum(), "n": g.size(), "gross_w": g["w"].sum()}) def describe(r: pd.Series) -> dict: 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())} 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) mem = members() files = sorted(f for f in os.listdir(BARS) if f.endswith(".pkl")) parts = [] for i, f in enumerate(files, 1): sym = f[:-4] bars = pd.read_pickle(os.path.join(BARS, f)) t = trades(bars) t["ret_opt"] = trades(bars, optimistic=True)["ret"] if sym in mem.columns: t["member"] = mem[sym].reindex(t.index).fillna(False).to_numpy() else: t["member"] = False t = t[(t["dir"] != 0) & t["member"] & (t["open"] > 5) & (t["avgvol"] >= 1e6) & (t["atr"] > 0.5) & (t["relvol"] >= 1)] t["symbol"] = sym parts.append(t) if i % 250 == 0: print(f" {i}/{len(files)}", flush=True) A = pd.concat(parts) A.index.name = "date" A = A.set_index("symbol", append=True) print(f"eligible name-days {len(A)}, days {A.index.get_level_values(0).nunique()}") sel = A.groupby(level="date", group_keys=False).apply(lambda g: g.nlargest(TOP, "relvol")) traded = sel[sel["ret"].notna()] res = {"universe_files": len(files), "eligible_name_days": int(len(A)), "selected_name_days": int(len(sel)), "triggered": int(len(traded))} days = pd.DatetimeIndex(sorted(set(A.index.get_level_values(0)))) B = {} opt = traded.assign(ret=traded["ret_opt"]) for name, extra, T in (("paper_costs", 0.0, traded), ("stress_5bp", STRESS, traded), ("optimistic_paper_costs", 0.0, opt), ("optimistic_stress_5bp", STRESS, opt)): b = book(T, extra).reindex(days).fillna({"ret": 0.0, "n": 0, "gross_w": 0.0}) B[name] = b r = b["ret"].loc[SPAN[0]:SPAN[1]] d = {"full": describe(r), "paper": describe(r.loc[PAPER[0]:PAPER[1]]), "post": describe(r.loc[POST[0]:]), "halves": [describe(r.loc[a:z]) for a, z in HALVES], "avg_positions": float(b["n"].loc[SPAN[0]:].mean()), "avg_gross_leverage": float(b["gross_w"].loc[SPAN[0]:].mean())} yr = (1 + r).groupby(r.index.year).prod() - 1 d["yearly"] = {int(k): float(v) for k, v in yr.items()} res[name] = d print(f"{name:12s} full cagr {d['full']['cagr']*100:6.1f}% sh {d['full']['sharpe']:5.2f} mdd " f"{d['full']['mdd']*100:6.1f}% | 2016-23 sh {d['paper']['sharpe']:5.2f} | 2024- sh " f"{d['post']['sharpe']:5.2f} cagr {d['post']['cagr']*100:6.1f}% | halves " f"{d['halves'][0]['sharpe']:5.2f} {d['halves'][1]['sharpe']:5.2f} | pos {d['avg_positions']:.1f} " f"lev {d['avg_gross_leverage']:.2f}") print(" yearly %:", {k: round(v * 100, 1) for k, v in d["yearly"].items()}) tr = traded.loc[SPAN[0]:] res["trade_stats"] = {"n": int(len(tr)), "win_rate": float((tr["ret"] > 0).mean()), "mean_ret_bp": float(tr["ret"].mean() * 1e4), "mean_R": float((tr["ret"] * tr["entry"] / (ATR_STOP * tr["atr"])).mean())} print("trades:", res["trade_stats"]) def gates(p, s): return {"O1": p["paper"]["sharpe"] >= 1.5, "O2": p["post"]["sharpe"] >= 1.0, "O3": s["full"]["sharpe"] >= 1.0, "O4": all(h["sharpe"] >= 1.0 for h in p["halves"])} checks = gates(res["paper_costs"], res["stress_5bp"]) checks_opt = gates(res["optimistic_paper_costs"], res["optimistic_stress_5bp"]) decision = ("CONTINUE" if all(checks.values()) else "RESOLVE" if all(checks_opt.values()) else "STOP") res["verdict"] = {"checks": checks, "checks_optimistic": checks_opt, "decision": decision} print(f"checks {checks} optimistic {checks_opt} -> {decision}") with open(res_path, "w") as fh: json.dump(res, fh, indent=2) pd.concat(B, axis=1).to_pickle(os.path.join(OUT, "daily.pkl")) traded.to_pickle(os.path.join(OUT, "trades.pkl")) from research.lab.trials import append_trials append_trials(os.path.join(RESULTS, "trials_us.jsonl"), [{"name": f"orb_stocks_in_play:{k}", **res[k]["full"]} for k in B], {"study": "orb_stocks_in_play", "role": "preregistered replication"}) if __name__ == "__main__": main()