""" A separate S&P 400 sleeve: does any of four preregistered signals earn alpha over IJH and improve the book more than holding IJH would? PYTHONPATH=. uv run python -m research.lab.midcap_sleeve --only h4 PYTHONPATH=. uv run python -m research.lab.midcap_sleeve --only h1 h2 h3 # needs us_mid_fund_raw.pkl Background (research/lab/universe_midcap.py): ranking S&P 400 names inside the large-cap momentum top 10 lost (Sharpe 0.86 vs 0.98, 2019-26); the S&P 400 winners were a few lottery tickets among many small losers. Here the mid caps get their own book and their own benchmark. Every sleeve: PIT S&P 400 members (research/lab/panel_mid.py, Tiingo- extended), top 30, equal weight, rebalance every 21 sessions with buffer 1 (a holding stays while its rank is within 60), next-open fills, 20 bp a side, averaged over the 21 rebalance phases. Definitions and the decision rule live in PREREG and are written to disk before the first run; they are textbook choices, not fitted (price coverage before 2017 is 46-78% and XBRL starts in 2009-11, so there is no clean design period to fit on). The book used for the portfolio test is the lab replay of overnight_recovery_top10 (S&P 500, 20 bp) saved by universe_midcap.py. Result (2026-10-08): all four REJECT. Holdout 2017-01..2026-09 (book alone Sharpe 0.88; 2/3 book + 1/3 IJH 0.85): sleeve CAGR Sharpe alpha/IJH (t) halves corr book book+sleeve c0 momentum (control) 7.1% 0.46 -1.0% (-0.39) -0.9 / -1.0 0.83 0.80 h1 earnings momentum 7.8% 0.45 -1.7% (-0.74) +1.0 / -4.3 0.60 0.83 h2 quality-screened momentum 14.0% 0.65 +3.5% (1.28) +5.8 / +1.6 0.70 0.86 h3 quality composite 13.2% 0.67 +3.4% (1.23) +4.3 / +2.4 0.52 0.91 h4 lottery-excluded momentum 6.2% 0.46 -1.7% (-1.07) -0.1 / -3.8 0.71 0.83 EW members 11.3% 0.59 +1.0% (1.18) IJH 10.1% 0.56 Price momentum among S&P 400 names loses to the index and moves with the large-cap book (corr 0.83), so it diversifies nothing. Quality (h2, h3) is the only family with positive alpha in both halves and the only one that helps the book (h3: 0.91 vs 0.85, corr 0.52), but at ~3.5%/yr with ~10% tracking error t is 1.2-1.3; t 2 would need about 25 years at this effect size. Boundary: gp_a covers 55-70% of members (no gross profit for banks, REITs), so h2's median screen also drops most financials. EDGAR CIKs are name-checked against iShares holding names (723/845 tickers; members covered 84-98% a year from 2017). """ from __future__ import annotations import argparse import json import os import warnings import numpy as np import pandas as pd from research.lab.alpha import alpha_stats from research.lab.factors import overnight_mom, rec_126 from research.lab.panel_mid import load_us_mid from research.lab.us_final import monthly_blend from research.lab.us_sweep import _blend, run warnings.filterwarnings("ignore") OUT = os.path.join("data", "research", "results", "midcap_sleeve") BOOK = os.path.join("data", "research", "results", "universe_midcap", "returns.pkl") HOLDOUT = ("2017-01-03", "2026-09-24") HALVES = [("2017-01-03", "2021-12-31"), ("2022-01-03", "2026-09-24")] REFERENCE = ("2010-01-04", "2016-12-30") KW = dict(top_n=30, buffer=1.0, every=21) PREREG = { "family": "S&P 400 sleeve, 4 hypotheses + 1 control; all run once, results reported whatever they are", "construction": "PIT S&P 400 members, top 30, equal weight, every 21 sessions, buffer 1, next-open fills, " "20 bp a side, 21 phases averaged", "signals": { "c0_momentum": "control: mean pct rank of overnight_mom (252d sum of log open/prev close, skip 21) " "and rec_126 (close / 126d low - 1) among members (the live overnight_recovery score)", "h1_earnings_momentum": "mean pct rank of sue and sue_rev (research/lab/fundamentals.py: seasonal " "change scaled by the std of the last 8, visible from the filing date)", "h2_quality_screened_momentum": "c0 score, eligible only if roa (TTM net income / assets) > 0 and " "gp_a >= the members' median that day", "h3_quality_composite": "mean pct rank of gp_a, roa, accruals (-(NI-CFO)/assets), net_issuance " "(-12m log change in shares)", "h4_lottery_excluded_momentum": "c0 score, eligible only if MAX (largest daily return, last 21 " "sessions) and idiosyncratic vol (63d, residual to IJH) are both " "below the members' 80th percentile that day", }, "holdout": HOLDOUT, "accept_if_all": [ "A1: alpha vs IJH over the holdout with Newey-West t >= 2.0, or t >= 1.5 and alpha > 0 in both " "2017-2021 and 2022-2026", "A2: Sharpe(2/3 book + 1/3 sleeve) > Sharpe(2/3 book + 1/3 IJH), monthly reset, holdout", "A3 (h2, h4 only): Sharpe(sleeve) > Sharpe(c0_momentum), holdout", ], "on_accept": "forward-only validation book; no production change", "multiple_testing": "4 hypotheses: a lone t just above 2 is weak evidence; report all of them", "reference_only": f"{REFERENCE}: price coverage 57-78%, survivor-biased", "status": "holdout overlaps periods seen in earlier mid/small scans; not independent OOS", } def lottery_ok(p, members: pd.DataFrame) -> pd.DataFrame: """True where MAX(21) and idiosyncratic vol(63) are both under the members' 80th percentile. Idio variance = var(r) - beta^2 var(m).""" r = p.close.pct_change(fill_method=None) m = p.extra["etf"]["IJH"].pct_change(fill_method=None).reindex(p.dates) mx = r.rolling(21, min_periods=15).max() var_r = r.rolling(63, min_periods=40).var() var_m = m.rolling(63, min_periods=40).var() cov = r.rolling(63, min_periods=40).cov(m) idio = np.sqrt((var_r - cov.pow(2).div(var_m, axis=0)).clip(lower=0)) rank = lambda x: x.where(members).rank(axis=1, pct=True) return (rank(mx) <= 0.8) & (rank(idio) <= 0.8) def score(factors: list[pd.DataFrame], eligible: pd.DataFrame) -> pd.DataFrame: return _blend(*[f.where(eligible).rank(axis=1, pct=True) for f in factors]).where(eligible) def sleeves(p, which: list[str]) -> dict[str, pd.DataFrame]: mem = p.member mom = [overnight_mom(p), rec_126(p)] out = {"c0_momentum": score(mom, mem)} if "h4" in which: out["h4_lottery_excluded_momentum"] = score(mom, mem & lottery_ok(p, mem)) if {"h1", "h2", "h3"} & set(which): from research.lab import fundamentals as fu F = fu.load_mid(p) if "h1" in which: out["h1_earnings_momentum"] = score([F["sue"], F["sue_rev"]], mem) if "h2" in which: gp = F["gp_a"].where(mem) ok = (F["roa"] > 0) & gp.ge(gp.median(axis=1), axis=0) out["h2_quality_screened_momentum"] = score(mom, mem & ok) if "h3" in which: out["h3_quality_composite"] = score([F["gp_a"], F["roa"], F["accruals"], F["net_issuance"]], mem) return out def sharpe(r: pd.Series) -> float: return float(r.mean() / r.std() * np.sqrt(252)) def describe(r: pd.Series, ijh: pd.Series, book: pd.Series) -> dict: h = r.loc[HOLDOUT[0]:HOLDOUT[1]] e = (1 + h).cumprod() a = alpha_stats(h, ijh) halves = [alpha_stats(r.loc[x:y], ijh)["alpha"] for x, y in HALVES] R = pd.DataFrame({"book": book, "s": r, "ijh": ijh}).loc[HOLDOUT[0]:HOLDOUT[1]].dropna() with_s = monthly_blend(R, {"book": 2 / 3, "s": 1 / 3}) with_i = monthly_blend(R, {"book": 2 / 3, "ijh": 1 / 3}) ref = r.loc[REFERENCE[0]:REFERENCE[1]] return {"cagr": float(e.iloc[-1] ** (252 / len(h)) - 1), "sharpe": sharpe(h), "mdd": float((e / e.cummax() - 1).min()), "alpha_ijh": float(a["alpha"]), "alpha_t": float(a["t"]), "beta_ijh": float(a["beta"]), "alpha_halves": [float(x) for x in halves], "corr_book": float(R["s"].corr(R["book"])), "book_sharpe_with_sleeve": sharpe(with_s), "book_sharpe_with_ijh": sharpe(with_i), "reference_sharpe": sharpe(ref), "years": {int(k): float(v) for k, v in ((1 + h).groupby(h.index.year).prod() - 1).items()}} def verdict(name: str, d: dict, c0: dict) -> dict: a1 = d["alpha_t"] >= 2.0 or (d["alpha_t"] >= 1.5 and min(d["alpha_halves"]) > 0) a2 = d["book_sharpe_with_sleeve"] > d["book_sharpe_with_ijh"] checks = {"A1": a1, "A2": a2} if name.startswith(("h2", "h4")): checks["A3"] = d["sharpe"] > c0["sharpe"] return {"checks": {k: bool(v) for k, v in checks.items()}, "verdict": "ACCEPT" if all(checks.values()) else "REJECT"} def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--only", nargs="+", default=["h1", "h2", "h3", "h4"]) which = ap.parse_args().only os.makedirs(OUT, exist_ok=True) pre = os.path.join(OUT, "prereg.json") if not os.path.exists(pre): with open(pre, "w") as fh: json.dump(PREREG, fh, indent=2) res_path = os.path.join(OUT, "results.json") done = json.load(open(res_path)) if os.path.exists(res_path) else {} clash = [k for k in done if k != "c0_momentum" and k.split("_")[0] in which] if clash: raise SystemExit(f"already run: {clash}; this study does not overwrite results") p = load_us_mid("mid") ijh = p.extra["etf"]["IJH"].pct_change(fill_method=None) book = pd.read_pickle(BOOK)["base"] S = sleeves(p, which) span = [(REFERENCE[0], HOLDOUT[1])] rets = {} for name, sc in S.items(): rets[name] = run(p, sc, span, **KW)["_rets"] print(f"ran {name}", flush=True) ew = p.close.pct_change(fill_method=None).where(p.member.shift(1, fill_value=False)).mean(axis=1) rets["bench_ew_members"] = ew rets["bench_ijh"] = ijh for name, r in rets.items(): d = describe(r.reindex(p.dates).fillna(0.0), ijh, book) if name.startswith("h"): d.update(verdict(name, d, done.get("c0_momentum") or describe(rets["c0_momentum"], ijh, book))) done[name] = d print(f"{name:32s} cagr {d['cagr']*100:5.1f}% sh {d['sharpe']:.2f} mdd {d['mdd']*100:6.1f}% " f"alpha {d['alpha_ijh']*100:5.1f}% t {d['alpha_t']:.2f} halves " f"{[round(x*100, 1) for x in d['alpha_halves']]} corr_book {d['corr_book']:.2f} " f"book+sleeve {d['book_sharpe_with_sleeve']:.2f} vs book+IJH {d['book_sharpe_with_ijh']:.2f} " f"ref {d['reference_sharpe']:.2f} {d.get('verdict', '')} {d.get('checks', '')}") with open(res_path, "w") as fh: json.dump(done, fh, indent=2) pd.DataFrame(rets).to_pickle(os.path.join(OUT, f"returns_{'_'.join(which)}.pkl")) from research.lab.trials import append_trials append_trials(os.path.join("data", "research", "results", "trials_us.jsonl"), [{"name": f"midcap_sleeve:{k}", **{x: y for x, y in v.items() if x != "years"}} for k, v in done.items() if k in rets], {"study": "midcap_sleeve", "role": "preregistered family; holdout partly seen"}) if __name__ == "__main__": main()