Files
quant-trading/strategies/svr_momentum.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

77 lines
3.0 KiB
Python

"""RobustMomentum + short-volume veto (FINRA REG SHO). RESEARCH ONLY — not registered.
Status: a 70%-quantile veto on overnight_recovery_top10 looked good on the
2026-10-07 window (Sharpe 0.87 -> 0.89, MaxDD -47.4% -> -39.4%), but the
quantile neighborhood killed it: veto at 0.60/0.75/0.80 all score BELOW the
baseline (Sharpe 0.81/0.82/0.84 vs 0.87). A lone spike, not a factor — the
strategy was registered as `overnight_recovery_svr70` and then reverted the
same day. Kept as an artifact; the veto idea is not robust on this sleeve.
Context: the raw cross-sectional reading stands (21-day SVR is a negative
predictor on the S&P 500 panel, IC -0.020 with t -3.1; within small-cap
momentum the high-SVR half underperforms -0.61%/mo), but turning that into a
top-10-portfolio exclusion rule is too concentrated to be stable.
Data: data/research/short_volume.pkl (research/lab/fetch_shortvolume.py;
missing file = no veto). REG SHO files publish after the close, so the veto
inside is lagged 2 sessions.
"""
from __future__ import annotations
import os
import pickle
import numpy as np
import pandas as pd
from strategies.robust_momentum import RobustMomentumStrategy, select_with_buffer
SVR_PATH = os.path.join("data", "research", "short_volume.pkl")
_SVR_CACHE: dict = {}
def svr21_panel() -> pd.DataFrame | None:
if "svr" not in _SVR_CACHE:
if not os.path.exists(SVR_PATH):
_SVR_CACHE["svr"] = None
else:
with open(SVR_PATH, "rb") as fh:
d = pickle.load(fh)
svr = d["short"] / d["total"].replace(0.0, np.nan)
_SVR_CACHE["svr"] = svr.rolling(21, min_periods=10).mean()
return _SVR_CACHE["svr"]
class RobustMomentumSvr(RobustMomentumStrategy):
"""RobustMomentum with a top-quantile short-volume veto at selection."""
def __init__(self, signal: str, svr_quant: float = 0.7, svr_lag: int = 2, **kw) -> None:
super().__init__(signal, **kw)
self.svr_quant = svr_quant
self.svr_lag = svr_lag
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:
close = bars["close"]
panel = {f: bars[f].reindex(index=close.index, columns=close.columns) for f in self.fields}
if "eligible" in bars:
panel["eligible"] = bars["eligible"]
score = self._score(panel)
eligible = close.notna()
if "eligible" in bars:
eligible &= bars["eligible"].reindex_like(close).fillna(False).astype(bool)
score = score.where(eligible)
svr = svr21_panel()
if svr is not None:
svr = svr.reindex(index=close.index, columns=close.columns).shift(self.svr_lag)
q = svr.rank(axis=1, pct=True)
veto = (q >= self.svr_quant) & svr.notna()
score = score.where(~veto)
weights = select_with_buffer(score, self.top_n, self.rebal_freq, self.buffer, self.warmup,
prices=close)
return weights.shift(1).fillna(0.0)
__all__ = ["RobustMomentumSvr"]