Files
quant-trading/research/lab/union_shield.py
Gahow Wang 04e2c5c3be research: commit the 2026-10-07/08 studies left in the working tree, overnight mid/small (STOP)
Studies run by earlier sessions and documented in CLAUDE.md, but never
committed: FINRA short volume (SVR) and the research-only svr_momentum veto,
SEC fails-to-deliver, 13D activists and the 13D event sleeve, 13F
institutional flow, GDELT news volume, S&P 400/600 Form 4 insiders,
union_shield, shield_lab; plus the mid/small insider and Tiingo fetcher
changes and midsmall_rebuild.

overnight_midsmall: the overnight-only momentum book on S&P 400/600 earns
0.93 net Sharpe at 3 bp a side (2019-26) with corr 0.83 to the S&P 500 book
(STOP).

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

143 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Left-tail control on the S&P 500 ∪ S&P 400 union book: does a daily
market-regime shield change the universe_midcap verdict?
Background (research/lab/universe_midcap.py): ranking S&P 400 names inside
overnight_recovery_top10 cost 4.7pp of CAGR and 0.12 of Sharpe (2019-26),
and its excess losses concentrated in down years (2022: -26pp). This applies
the same overlay to both arms and asks whether the union becomes acceptable
once the crash tail is cut.
PYTHONPATH=. uv run python -m research.lab.union_shield
Prereg (written before the run): exposure_t = 1{SPY close through t-1 above
its 150-day average through t-1}, else cash (0 return); applied to the
tranche-averaged daily book returns saved by universe_midcap.py;
20 bp charged per unit of exposure change. One mechanism, no variants.
Result (2026-10-09): FAIL. The shield hurts every block of the base book
(whipsaw: it sits out 24-38% of 2020/2021/2025 bull legs) — base
29.4%/0.98 -> base+shield 16.1%/0.70 — and union+shield (15.5%/0.67) lands
below both base+shield (P1) and the plain unshielded base (P2). MaxDDs are
unchanged to the decimal (-34.3% base, -38.8% union+shield), so the filter
buys no tail either. The union ordering (union >= adv20 > minus_top3) is
unchanged under the shield. Conclusion: left-tail control does not salvage
the S&P 400 extension; a SPY>MA150 regime filter is simply bad for this
momentum book.
"""
from __future__ import annotations
import json
import os
import warnings
import numpy as np
import pandas as pd
from research.lab.alpha import alpha_stats
from research.lab.panel_mid import load_us_mid
warnings.filterwarnings("ignore")
RESULTS = os.path.join("data", "research", "results", "universe_midcap")
OUT = os.path.join("data", "research", "results", "union_shield")
PRIMARY = ("2019-01-02", "2026-09-24")
BLOCKS = [("2019-01-02", "2020-12-31"), ("2021-01-04", "2022-12-30"),
("2023-01-03", "2024-12-31"), ("2025-01-02", "2026-09-24")]
REFERENCE = ("2007-01-03", "2018-12-31")
COST = 0.002
PREREG = {
"question": "With a SPY>MA150 regime shield (else cash) applied to both arms, does adding PIT "
"S&P 400 members to overnight_recovery_top10 still lose?",
"arms": ["base", "base+shield", "union", "union+shield", "union_adv20+shield (reported)"],
"mechanics": "exposure_t = 1{SPY close through t-1 > its 150d average through t-1}, else 0 (cash); "
"r'_t = e_t r_t - 0.002 |e_t - e_{t-1}|; inputs = universe_midcap/returns.pkl "
"(tranche-averaged, 20 bp a side already inside)",
"primary_window": PRIMARY,
"pass_if_all": [
"P1: Sharpe(union+shield) > Sharpe(base+shield) (universe A/B under the same overlay)",
"P2: Sharpe(union+shield) >= Sharpe(base) (vs the live book, unshielded)",
"P3: MaxDD(union+shield) >= MaxDD(base) - 5pp",
],
"on_pass": "the union deserves a shield-equipped forward check; no production change from this alone",
"status": "the window was already examined; not independent OOS; one mechanism, no variants",
}
def window_stats(r: pd.Series, a: str, b: str) -> dict:
x = r.loc[a:b]
e = (1 + x).cumprod()
yrs = len(x) / 252
return {"cagr": float(e.iloc[-1] ** (1 / yrs) - 1),
"sharpe": float(x.mean() / x.std() * np.sqrt(252)),
"mdd": float((e / e.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 itself")
if not os.path.exists(os.path.join(OUT, "prereg.json")):
with open(os.path.join(OUT, "prereg.json"), "w") as fh:
json.dump(PREREG, fh, indent=2)
R = pd.read_pickle(os.path.join(RESULTS, "returns.pkl"))
p = load_us_mid("mid")
spy = p.extra["etf"]["SPY"]
ma = spy.rolling(150, min_periods=120).mean()
expo = (spy.shift(1) > ma.shift(1)).astype(float).reindex(R.index).ffill().fillna(0.0)
d = expo.diff().abs().fillna(0.0)
S = {}
for arm in R.columns:
S[arm] = R[arm]
S[f"{arm}+shield"] = expo * R[arm] - COST * d
res = {}
for arm, r in S.items():
res[arm] = {w: window_stats(r, *ab) for w, ab in
[("primary", PRIMARY), ("reference", REFERENCE)]}
res[arm]["blocks"] = [round(window_stats(r, *ab)["sharpe"], 3) for ab in BLOCKS]
res[arm]["years"] = {int(k): round(float(v), 4) for k, v in
((1 + r.loc[PRIMARY[0]:PRIMARY[1]]).groupby(
r.loc[PRIMARY[0]:PRIMARY[1]].index.year).prod() - 1).items()}
a = alpha_stats(S["union+shield"].loc[PRIMARY[0]:PRIMARY[1]],
S["base"].loc[PRIMARY[0]:PRIMARY[1]])
res["union+shield"]["alpha_vs_base"] = {k: float(a[k]) for k in ("alpha", "t")}
res["exposure_share_by_year"] = {int(y): round(float(v), 3) for y, v in
expo.loc[PRIMARY[0]:PRIMARY[1]].groupby(
expo.loc[PRIMARY[0]:PRIMARY[1]].index.year).mean().items()}
base_, base_sh = res["base"]["primary"], res["base+shield"]["primary"]
union_sh = res["union+shield"]["primary"]
checks = {"P1_union_shield_gt_base_shield": union_sh["sharpe"] > base_sh["sharpe"],
"P2_union_shield_ge_base": union_sh["sharpe"] >= base_["sharpe"],
"P3_mdd_not_worse_5pp": union_sh["mdd"] >= base_["mdd"] - 0.05}
verdict = "PASS" if all(checks.values()) else "FAIL"
summary = {"prereg": PREREG, "results": res, "checks": {k: bool(v) for k, v in checks.items()},
"verdict": verdict}
with open(res_path, "w") as fh:
json.dump(summary, fh, indent=2, default=float)
pd.DataFrame(S).to_pickle(os.path.join(OUT, "returns.pkl"))
for arm in ("base", "base+shield", "union", "union+shield", "union_adv20+shield", "union_minus_top3+shield"):
s = res[arm]
print(f"{arm:22s} primary: cagr {s['primary']['cagr']*100:5.1f}% sh {s['primary']['sharpe']:.2f} "
f"mdd {s['primary']['mdd']*100:5.1f}% blocks {s['blocks']} ref {s['reference']['sharpe']:.2f}")
print("exposure share by year:", res["exposure_share_by_year"])
print("union+shield alpha vs base:", res["union+shield"]["alpha_vs_base"])
print("checks:", summary["checks"], "→", verdict)
from research.lab.trials import append_trials
append_trials(os.path.join("data", "research", "results", "trials_us.jsonl"),
[{"name": f"union_shield:{k}", **{f"{w}_{x}": y for w in ("primary", "reference")
for x, y in v.items() if isinstance(y, (int, float))}}
for k, v in res.items() if isinstance(v, dict) and "primary" in v],
{"study": "union_shield", "role": "preregistered overlay on a previously seen window"})
if __name__ == "__main__":
main()