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