- 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>
213 lines
10 KiB
Python
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()
|