Files
quant-trading/research/lab/small_cap_reversal.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

213 lines
10 KiB
Python

"""
Quick check: is short-term reversal (liquidity provision) in S&P 400/600
names a market-neutral stream strong enough to lever, now that a $1M account
can trade where large funds cannot?
PYTHONPATH=. uv run python -m research.lab.small_cap_reversal
Panels: the PIT S&P 400/600 union (research/lab/panel_mid.py, Tiingo
extended; member coverage 77% of the 1000 names in 2017, 99% in 2026) and the
PIT S&P 500 (factor_scan.load("us")) as a large-cap consistency check.
Strategy (Lehmann 1990; Nagel 2012), close prices >= $5 only:
signal 5-day return minus the cross-sectional mean, from closes through t
basket the lowest decile (losers), equal weight
holding enter at the open of t+1, exit at the open of t+6 (five
overlapping tranches, each 1/5 of the book; at least 5 sessions held)
hedge short the equal-weight member universe (beta 1), which an
equal-weight index future or ETF approximates; the hedge's own
costs are ignored here
returns open-to-open, so book_d = sum over L=2..6 of 1/5 x mean(r_d over basket_{d-L})
costs per name and side: Abdi-Ranaldo (2017) half-spread from daily
high/low/close (21-day mean) + 2 bp, charged on the change in the
aggregate weights; a flat 20 bp/side variant is reported too
The rule is written to prereg.json before the run; the script refuses to
overwrite results.json. 2017-26 of the mid/small panel has not been used for a
reversal test before; the S&P 500 reversal sleeves of short_horizon.py were
long-only and monthly/weekly, so this is not a repeat but not untouched data.
Result (2026-10-08): STOP. C1, C2 and C3 all fail.
- midsmall rev5 hedged: gross Sharpe 0.58 (about 7%/yr at 12% vol). Net is
-22%/yr at the Abdi-Ranaldo cost (median 17 bp a side) and -10%/yr at a
flat 20 bp. Every year 2017-2026 is negative.
- Turnover is 0.34 of the book a day (~86x a year), so breakeven is about
8 bp a side, under what MOO orders in S&P 400/600 names cost.
- S&P 500 2006-26: gross 0.61; net -1.34 (-0.83 at 20 bp).
- Quiet (low abnormal volume) losers are weaker, not stronger (gross 0.45).
- Bug in the reported-only long_short row: net(losers) - net(winners)
adds the winners' costs back. That row (0.46 / 0.57) is near gross,
not net.
Interpretation: daily-frequency liquidity provision earns less than the
spread a non-market-maker pays. The edge sits intraday, with market makers.
"""
from __future__ import annotations
import json
import os
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS, load
from research.lab.levered_book import stats, tbill
from research.lab.panel_mid import load_us_mid
OUT = os.path.join(RESULTS, "small_cap_reversal")
SPAN_MID = ("2017-01-03", "2026-09-24")
HALVES_MID = [("2017-01-03", "2021-12-31"), ("2022-01-03", "2026-09-24")]
SPAN_LARGE = ("2006-10-02", "2026-09-24")
HOLD, LOOK, MIN_PRICE, FEE = 5, 5, 5.0, 0.0002
PREREG = {
"question": "Is 5-day reversal in S&P 400/600 names, hedged with the EW universe, a stream to lever?",
"primary": "midsmall rev5 losers decile, hold 5, EW-universe hedge, Abdi-Ranaldo costs, 2017-01..2026-09",
"continue_if_all": [
"C1 primary net Sharpe >= 1.0 over 2017-26",
"C2 primary net Sharpe >= 0.5 in 2017-21 and in 2022-26",
"C3 S&P 500 version net Sharpe >= 0.3 over 2006-26 (the effect exists beyond one panel)",
],
"reported_not_decisive": ["gross Sharpe", "flat 20 bp/side costs", "long-short (losers - winners)",
"low-abnormal-volume losers (Nagel/Avramov: no-news moves revert more)",
"turnover", "monthly corr with the three-sleeve book"],
"decision": {"CONTINUE": "full study: hedge instrument, capacity at $1M, MOO execution, leverage",
"STOP": "drop short-horizon liquidity provision at daily frequency"},
"sharpe": "hedged stream: mean/std of daily net returns, annualized (no T-bill: it is self-financing "
"on top of cash)",
}
def half_spread(p) -> pd.DataFrame:
"""Abdi-Ranaldo (2017) close-high-low half-spread estimate, 21-day mean, floored at 0."""
c = np.log(p.close)
eta = (np.log(p.high) + np.log(p.low)) / 2
s2 = 4 * (c - eta) * (c - eta.shift(-1))
s2 = s2.shift(1) # uses eta through t only after the shift
s = np.sqrt(s2.rolling(21, min_periods=10).mean().clip(lower=0))
return (s / 2).clip(upper=0.02)
def tranche_weights(score: pd.DataFrame, pick: pd.DataFrame) -> pd.DataFrame:
w = pick.astype(float)
return w.div(w.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)
def book(p, basket: pd.DataFrame, universe: pd.DataFrame, cost_side: pd.DataFrame | float,
hedge: bool = True) -> pd.DataFrame:
"""Daily open-to-open returns of the overlapping-tranche book, gross and net."""
ro = (p.open / p.open.shift(1) - 1).where(lambda x: x.abs() < 1.0)
w = tranche_weights(None, basket)
H = sum(w.shift(L) for L in range(2, 2 + HOLD)) / HOLD
gross = (H * ro.fillna(0.0)).sum(axis=1)
if hedge:
u = universe.shift(2) # hedge sized to the same information
gross = gross - ro.where(u).mean(axis=1).fillna(0.0) * (H.sum(axis=1) > 0)
dH = (H - H.shift(1)).abs()
cost = (dH * cost_side).sum(axis=1) if isinstance(cost_side, pd.DataFrame) else dH.sum(axis=1) * cost_side
return pd.DataFrame({"gross": gross, "net": gross - cost, "turnover": dH.sum(axis=1)})
def signals(p):
c = p.close
ok = p.member & (c >= MIN_PRICE) & c.notna() & p.open.notna()
r5 = (c / c.shift(LOOK) - 1).where(ok)
adj = r5.sub(r5.mean(axis=1), axis=0)
rk = adj.rank(axis=1, pct=True)
losers = rk <= 0.10
winners = rk > 0.90
dv = (c * p.volume)
abn = (dv.rolling(LOOK).mean() / dv.rolling(63, min_periods=40).mean().shift(LOOK)).where(ok)
quiet = losers & (abn.rank(axis=1, pct=True) <= 0.5)
return ok, losers, winners, quiet
def sharpe(r: pd.Series) -> float:
r = r.dropna()
return float(r.mean() / r.std() * np.sqrt(252)) if r.std() > 0 else float("nan")
def describe(r: pd.Series, span, halves) -> dict:
r = r.loc[span[0]:span[1]]
eq = (1 + r).cumprod()
return {"ann_ret": float(r.mean() * 252), "vol": float(r.std() * np.sqrt(252)), "sharpe": sharpe(r),
"mdd": float((eq / eq.cummax() - 1).min()),
"halves": [sharpe(r.loc[a:b]) for a, b in halves]}
def run_panel(p, span, halves, tag: str) -> tuple[dict, pd.DataFrame]:
ok, losers, winners, quiet = signals(p)
hs = half_spread(p) + FEE
hs = hs.fillna(hs.stack().median())
out, R = {}, {}
variants = {
"rev5_hedged": (losers, hs, True),
"rev5_hedged_flat20": (losers, 0.002, True),
"rev5_quiet_hedged": (quiet, hs, True),
"rev5_unhedged": (losers, hs, False),
}
for k, (bk, cs, hedge) in variants.items():
b = book(p, bk, ok, cs, hedge)
R[f"{k}_net"], R[f"{k}_gross"] = b["net"], b["gross"]
d = describe(b["net"], span, halves)
d["gross_sharpe"] = sharpe(b["gross"].loc[span[0]:span[1]])
d["turnover_per_day"] = float(b["turnover"].loc[span[0]:span[1]].mean())
out[k] = d
lw = book(p, losers, ok, hs, False)["net"] - book(p, winners, ok, hs, False)["net"]
R["long_short_net"] = lw
out["long_short"] = describe(lw, span, halves)
med = hs.where(ok).loc[span[0]:span[1]].stack().median()
out["median_half_spread_plus_fee_bp"] = float(med * 1e4)
for k, d in out.items():
if isinstance(d, dict):
print(f"[{tag}] {k:22s} ann {d['ann_ret']*100:6.1f}% vol {d['vol']*100:5.1f}% sh {d['sharpe']:5.2f} "
f"mdd {d['mdd']*100:6.1f}% halves {d['halves'][0]:5.2f} {d['halves'][1]:5.2f}"
+ (f" gross sh {d['gross_sharpe']:5.2f} turn/day {d['turnover_per_day']:.2f}"
if "gross_sharpe" in d else ""))
print(f"[{tag}] median cost per side {med*1e4:.1f} bp")
return out, pd.DataFrame(R)
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)
res = {}
mid = load_us_mid("midsmall")
res["midsmall"], Rm = run_panel(mid, SPAN_MID, HALVES_MID, "midsmall")
large, _ = load("us")
res["sp500"], Rl = run_panel(large, SPAN_LARGE, [("2006-10-02", "2016-12-30"), ("2017-01-03", "2026-09-24")],
"sp500")
yr = (1 + Rm["rev5_hedged_net"].loc[SPAN_MID[0]:SPAN_MID[1]]).groupby(
Rm.loc[SPAN_MID[0]:SPAN_MID[1]].index.year).prod() - 1
print("midsmall rev5_hedged net yearly %:", (yr * 100).round(1).to_dict())
res["yearly_mid_primary"] = {int(k): float(v) for k, v in yr.items()}
try:
S = pd.read_pickle(os.path.join(RESULTS, "managed_futures_book", "books.pkl"))["three_sleeve_v7"]
m = lambda x: (1 + x.dropna()).resample("ME").prod() - 1
res["corr_three_sleeve"] = float(m(Rm["rev5_hedged_net"].loc[SPAN_MID[0]:]).corr(m(S)))
print(f"monthly corr with three_sleeve_v7: {res['corr_three_sleeve']:.2f}")
except FileNotFoundError:
pass
pm, pl = res["midsmall"]["rev5_hedged"], res["sp500"]["rev5_hedged"]
checks = {"C1": pm["sharpe"] >= 1.0, "C2": all(x >= 0.5 for x in pm["halves"]), "C3": pl["sharpe"] >= 0.3}
res["verdict"] = {"checks": checks, "decision": "CONTINUE" if all(checks.values()) else "STOP"}
print(f"checks {checks} -> {res['verdict']['decision']}")
with open(res_path, "w") as fh:
json.dump(res, fh, indent=2)
pd.concat({"midsmall": Rm, "sp500": Rl}, axis=1).to_pickle(os.path.join(OUT, "returns.pkl"))
from research.lab.trials import append_trials
append_trials(os.path.join(RESULTS, "trials_us.jsonl"),
[{"name": f"small_cap_reversal:{panel}:{k}", **v} for panel in ("midsmall", "sp500")
for k, v in res[panel].items() if isinstance(v, dict)],
{"study": "small_cap_reversal", "role": "preregistered quick check"})
if __name__ == "__main__":
main()