Files
quant-trading/research/lab/orb_stocks_in_play.py
Gahow Wang c34367161d research: ORB (STOP), overnight-only momentum (STOP), Alpaca paper forward test
- orb_stocks_in_play: 5-min bounds -70%/yr (conservative) vs +71%/yr
  (optimistic); the optimistic bound breaks even at 3.6 bp slippage a side.
- overnight_only: the momentum sleeve's names earn their return overnight
  (Sharpe 1.57) and nothing intraday; held close->open only, net Sharpe
  1.29-1.42 at 1 bp a side, below the 1.5 gate.
- overnight_paper.py + systemd unit: MOC buy / MOO sell of the top 10 on the
  Alpaca paper account from 2026-10-12, with a fills-vs-prints report.

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

271 lines
13 KiB
Python

"""
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()