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

133 lines
5.7 KiB
Python

"""Shielded stock sleeves: V9 risk machinery on top of stock selection.
The frontier battle so far lives in the leveraged-ETF trend family because
the cash-funded ledger forbids margin: only 3x instruments can reach the
CAGR bar. This lab quantifies the alternative the selection camp asks for —
take the BEST stock-selection sleeves (overnight_recovery top-10, low_risk
top-20) and wrap them in the V9/V10 machinery:
regime shield : SPY > MA150 (through t-1) -> hold sleeve; else hold the
momentum leader of GLD/DBC
vol target : optional, scale = clip(target/vol60, min, 1), shift(1)
trail stop : optional basket-level trailing stop -> risk-off asset
PIT: all overlays use data through t-1 and one terminal shift, same
convention as TrendRiderV3.
PYTHONPATH=. uv run python -m research.shield_lab
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import trader
from strategies.base import BarStrategy
from strategies.robust_momentum import RobustMomentumStrategy
import research.tr9_lab as lab
class ShieldedMomentum(RobustMomentumStrategy):
"""Stock sleeve + SPY-regime shield (and optional vol target / trail)."""
def __init__(self, signal: str, ma_long: int = 150, risk_off: tuple[str, ...] = ("GLD", "DBC"),
mom_lookback: int = 63, vol_target: float | None = None,
trail_pct: float | None = None, trail_window: int = 30, **kw) -> None:
super().__init__(signal, **kw)
self.ma_long = ma_long
self.risk_off = risk_off
self.mom_lookback = mom_lookback
self.vol_target = vol_target
self.trail_pct = trail_pct
self.trail_window = trail_window
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:
w_s = super().generate_weights(bars) # already .shift(1)'d, stocks only
close = bars["close"]
# keep the overlay instruments out of the base sleeve's selection
extras_present = [c for c in ("SPY", *self.risk_off) if c in w_s.columns]
if extras_present and w_s[extras_present].to_numpy().any():
w_s = w_s.copy()
w_s[extras_present] = 0.0
w_s = w_s.div(w_s.sum(axis=1).replace(0.0, np.nan), axis=0).fillna(0.0)
if "SPY" not in close.columns:
return w_s
spy = close["SPY"]
risk_on = (spy > spy.rolling(self.ma_long).mean()).shift(1).fillna(False)
# momentum leader among risk-off assets (through t-1)
ro = [c for c in self.risk_off if c in close.columns]
if ro:
mom = close[ro].pct_change(self.mom_lookback).shift(1)
leader = mom.idxmax(axis=1).where(mom.max(axis=1).notna(), ro[0])
else:
leader = pd.Series("", index=close.index)
out = pd.DataFrame(0.0, index=close.index,
columns=sorted(set(w_s.columns) | set(ro)))
out[w_s.columns] = w_s
off_rows = ~risk_on
for c in w_s.columns:
out.loc[off_rows, c] = 0.0
for i in np.where(off_rows)[0]:
out.iat[i, out.columns.get_loc(leader.iloc[i])] = 1.0
if self.trail_pct:
# basket-level trailing stop on the sleeve NAV; ultra-conservative
# lag: the trigger at output row i uses NAV through row i-2
base_ret = (w_s * close[w_s.columns].pct_change(fill_method=None)).sum(axis=1)
nav = (1 + base_ret.fillna(0.0)).cumprod()
trig = ((nav / nav.rolling(self.trail_window, min_periods=5).max() - 1.0)
<= -self.trail_pct).shift(2).fillna(False)
state, park_rows = False, []
for i in range(len(trig)):
if not risk_on.iloc[i]:
state = False
elif trig.iloc[i]:
state = True
park_rows.append(state)
park = pd.Series(park_rows, index=close.index) & risk_on
for i in np.where(park)[0]:
for c in w_s.columns:
out.iat[i, out.columns.get_loc(c)] = 0.0
out.iat[i, out.columns.get_loc(leader.iloc[i])] = 1.0
if self.vol_target:
rets = (out.shift(0) * close.pct_change(fill_method=None).reindex_like(out)).sum(axis=1)
realized = rets.rolling(60, min_periods=21).std() * np.sqrt(252)
scale = (self.vol_target / realized).clip(0.6, 1.0).shift(1).fillna(1.0)
out = out.mul(scale, axis=0)
return out
def register() -> list[str]:
reg = trader.STRATEGY_REGISTRY
names = []
variants = {
"sh_orr_shield": lambda: ShieldedMomentum("overnight_recovery", top_n=10, rebal_freq=10,
buffer=2.0),
"sh_orr_shield_vt": lambda: ShieldedMomentum("overnight_recovery", top_n=10, rebal_freq=10,
buffer=2.0, vol_target=0.28),
"sh_orr_full": lambda: ShieldedMomentum("overnight_recovery", top_n=10, rebal_freq=10,
buffer=2.0, vol_target=0.28, trail_pct=0.20),
"sh_lowrisk_shield_vt": lambda: ShieldedMomentum("low_risk", top_n=20, rebal_freq=21,
buffer=2.0, vol_target=0.22),
}
for n, f in variants.items():
reg[n] = f
trader.MIXED_STRATEGY_EXTRA_TICKERS[n] = ["SPY", "GLD", "DBC"]
names.append(n)
return names
def main() -> None:
names = register()
curves = lab.run(["overnight_recovery_top10", "lowrisk_top20", "us_three_sleeve",
"trend_rider_v10"] + names, end="2026-10-07")
lab.report(curves, "shielded sleeves vs frontier")
if __name__ == "__main__":
main()