Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band. Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
219 lines
9.8 KiB
Python
219 lines
9.8 KiB
Python
"""
|
|
Robust momentum strategies from the 2026-09 point-in-time factor research
|
|
(``research/lab``). They were selected by their worst 5-year Sharpe over
|
|
2006/2010-2026 on PIT index members with next-open execution, not by their
|
|
best window.
|
|
|
|
Each score averages cross-sectional percentile ranks of its factors. Holdings
|
|
are the top ``top_n`` names, re-selected every ``rebal_freq`` rows; a held
|
|
name stays while its rank is within ``top_n * (1 + buffer)`` (hysteresis cuts
|
|
turnover without giving up the signal). Positions are equal-weighted on
|
|
rebalance rows and drift with prices in between, so winners are not trimmed
|
|
daily (worth ~3-4 pp a year for overnight_recovery against daily re-weighting).
|
|
|
|
Signals:
|
|
overnight_recovery (US) overnight-return momentum (12m, skip 1m)
|
|
+ distance from the 126-day low
|
|
lowrisk_momentum (CN) amplitude-cut momentum + low beta
|
|
+ negative price-volume correlation + momentum/vol
|
|
lowbeta_sharpe_mom (CN) low beta + momentum/vol (close prices only)
|
|
low_risk (US) low idiosyncratic volatility + low beta (close only);
|
|
the defensive sleeve of the 2026-09-29 US portfolio
|
|
research, meant to be held next to overnight_recovery
|
|
and trend_rider_v7_vt24 (monthly correlation 0.2-0.4)
|
|
"""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from numpy.lib.stride_tricks import sliding_window_view
|
|
|
|
from strategies.base import BarStrategy, mask_scores
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Factors: higher = more attractive; row t uses data through t
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _returns(close: pd.DataFrame) -> pd.DataFrame:
|
|
return close.pct_change(fill_method=None)
|
|
|
|
|
|
def overnight_momentum(open_: pd.DataFrame, close: pd.DataFrame,
|
|
window: int = 252, skip: int = 21) -> pd.DataFrame:
|
|
"""Sum of log overnight returns (open / previous close), Lou-Polk-Skouras."""
|
|
overnight = np.log(open_ / close.ffill().shift(1))
|
|
return overnight.rolling(window, min_periods=180).sum().shift(skip)
|
|
|
|
|
|
def recovery(close: pd.DataFrame, window: int = 126) -> pd.DataFrame:
|
|
c = close.ffill()
|
|
return c / c.rolling(window, min_periods=window).min() - 1
|
|
|
|
|
|
def momentum_12_1(close: pd.DataFrame) -> pd.DataFrame:
|
|
c = close.ffill()
|
|
return c.shift(21) / c.shift(252) - 1
|
|
|
|
|
|
def sharpe_momentum(close: pd.DataFrame) -> pd.DataFrame:
|
|
"""12-1 month return divided by 12-month daily volatility."""
|
|
return momentum_12_1(close) / _returns(close).rolling(252, min_periods=180).std()
|
|
|
|
|
|
def low_beta(close: pd.DataFrame, window: int = 252, eligible=None) -> pd.DataFrame:
|
|
"""Negative rolling beta to the equal-weight average of the panel."""
|
|
r = _returns(close)
|
|
m = mask_scores(r, eligible).mean(axis=1).fillna(0.0)
|
|
minp = window * 180 // 252
|
|
e_xy = r.mul(m, axis=0).rolling(window, min_periods=minp).mean()
|
|
e_x = r.rolling(window, min_periods=minp).mean()
|
|
e_y = m.rolling(window, min_periods=minp).mean()
|
|
var_y = (m ** 2).rolling(window, min_periods=minp).mean() - e_y ** 2
|
|
return -(e_xy - e_x.mul(e_y, axis=0)).div(var_y, axis=0)
|
|
|
|
|
|
def idiosyncratic_vol(close: pd.DataFrame, beta_window: int = 252,
|
|
vol_window: int = 63, eligible=None) -> pd.DataFrame:
|
|
"""Negative 63-day volatility of returns net of the equal-weight market
|
|
times a trailing (lagged) 252-day beta; Ang, Hodrick, Xing & Zhang (2006)."""
|
|
r = _returns(close)
|
|
m = mask_scores(r, eligible).mean(axis=1).fillna(0.0)
|
|
beta = -low_beta(close, beta_window, eligible).shift(1)
|
|
resid = r - beta.mul(m, axis=0)
|
|
return -resid.rolling(vol_window, min_periods=40).std()
|
|
|
|
|
|
def price_volume_corr(close: pd.DataFrame, volume: pd.DataFrame) -> pd.DataFrame:
|
|
"""Negative 21-day correlation of price and dollar volume."""
|
|
return -close.ffill().rolling(21, min_periods=15).corr(close * volume)
|
|
|
|
|
|
def amplitude_cut_momentum(high: pd.DataFrame, low: pd.DataFrame, close: pd.DataFrame,
|
|
window: int = 160, keep: float = 0.7) -> pd.DataFrame:
|
|
"""Sum of daily returns over the ``keep`` share of lowest-amplitude
|
|
(high/low - 1) days in the window (开源证券, 2020): calm up-moves persist,
|
|
noisy ones reverse."""
|
|
amp = (high / low - 1).to_numpy(dtype=float)
|
|
r = _returns(close).to_numpy(dtype=float)
|
|
out = np.full(r.shape, np.nan)
|
|
if len(r) >= window:
|
|
A = sliding_window_view(np.nan_to_num(amp, nan=np.inf), window, axis=0)
|
|
R = sliding_window_view(np.nan_to_num(r, nan=0.0), window, axis=0)
|
|
for t0 in range(0, A.shape[0], 256): # chunks bound memory
|
|
a, rr = A[t0:t0 + 256], R[t0:t0 + 256]
|
|
thr = np.nanquantile(np.where(np.isinf(a), np.nan, a), keep, axis=2, keepdims=True)
|
|
out[t0 + window - 1:t0 + window - 1 + a.shape[0]] = np.where(a <= thr, rr, 0.0).sum(axis=2)
|
|
valid = pd.DataFrame(amp, index=close.index).notna().rolling(window).sum() >= 0.8 * window
|
|
return pd.DataFrame(out, index=close.index, columns=close.columns).where(valid.to_numpy())
|
|
|
|
|
|
def _rank(df: pd.DataFrame) -> pd.DataFrame:
|
|
return df.rank(axis=1, pct=True)
|
|
|
|
|
|
def _composite(*factors: pd.DataFrame, eligible=None) -> pd.DataFrame:
|
|
return sum(_rank(mask_scores(f, eligible)) for f in factors) / len(factors)
|
|
|
|
|
|
def signal_overnight_recovery(bars):
|
|
return _composite(overnight_momentum(bars["open"], bars["close"]), recovery(bars["close"]), eligible=bars.get("eligible"))
|
|
|
|
|
|
def signal_lowrisk_momentum(bars):
|
|
c = bars["close"]
|
|
return _composite(amplitude_cut_momentum(bars["high"], bars["low"], c), low_beta(c, eligible=bars.get("eligible")),
|
|
price_volume_corr(c, bars["volume"]), sharpe_momentum(c), eligible=bars.get("eligible"))
|
|
|
|
|
|
def signal_lowbeta_sharpe_mom(bars):
|
|
c = bars["close"]
|
|
return _composite(low_beta(c, eligible=bars.get("eligible")), sharpe_momentum(c), eligible=bars.get("eligible"))
|
|
|
|
|
|
def signal_low_risk(bars):
|
|
c = bars["close"]
|
|
return _composite(idiosyncratic_vol(c, eligible=bars.get("eligible")),
|
|
low_beta(c, eligible=bars.get("eligible")), eligible=bars.get("eligible"))
|
|
|
|
|
|
# name -> (bar fields, score function)
|
|
SIGNALS = {
|
|
"overnight_recovery": (("close", "open"), signal_overnight_recovery),
|
|
"lowrisk_momentum": (("close", "high", "low", "volume"), signal_lowrisk_momentum),
|
|
"lowbeta_sharpe_mom": (("close",), signal_lowbeta_sharpe_mom),
|
|
"low_risk": (("close",), signal_low_risk),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def select_with_buffer(score: pd.DataFrame, top_n: int, rebal_freq: int,
|
|
buffer: float, warmup: int,
|
|
prices: pd.DataFrame | None = None) -> pd.DataFrame:
|
|
"""Equal weights on rebalance rows (every ``rebal_freq`` from ``warmup``);
|
|
in between they drift with ``prices`` (row k uses prices through k), or
|
|
stay constant when ``prices`` is None. Not yet shifted."""
|
|
S = score.to_numpy(dtype=float)
|
|
n, m = S.shape
|
|
W = np.full((n, m), np.nan)
|
|
held = np.zeros(m, dtype=bool)
|
|
for r in range(warmup, n, rebal_freq):
|
|
row = S[r]
|
|
valid = ~np.isnan(row)
|
|
w = np.zeros(m)
|
|
if valid.sum() >= top_n:
|
|
order = np.where(valid)[0][np.argsort(-row[valid], kind="stable")]
|
|
rank = np.full(m, np.inf)
|
|
rank[order] = np.arange(len(order))
|
|
keep = [i for i in np.where(held)[0] if rank[i] < top_n * (1 + buffer)]
|
|
chosen = sorted(keep, key=lambda i: rank[i])[:top_n]
|
|
taken = set(chosen)
|
|
for i in order:
|
|
if len(chosen) >= top_n:
|
|
break
|
|
if i not in taken:
|
|
chosen.append(i)
|
|
w[chosen] = 1.0 / len(chosen)
|
|
W[r] = w
|
|
held = w > 0
|
|
weights = pd.DataFrame(W, index=score.index, columns=score.columns).ffill().fillna(0.0)
|
|
weights.iloc[:warmup] = 0.0
|
|
if prices is not None:
|
|
px = prices.reindex_like(weights).ffill()
|
|
anchor = px.where(pd.Series(~np.isnan(W).all(axis=1), index=weights.index), axis=0).ffill()
|
|
grown = weights * (px / anchor).fillna(1.0)
|
|
weights = grown.div(grown.sum(axis=1).replace(0.0, np.nan), axis=0).fillna(0.0)
|
|
return weights
|
|
|
|
|
|
class RobustMomentumStrategy(BarStrategy):
|
|
def __init__(self, signal: str, top_n: int = 10, rebal_freq: int = 10,
|
|
buffer: float = 2.0, warmup: int = 252):
|
|
if signal not in SIGNALS:
|
|
raise ValueError(f"Unknown signal {signal!r}; available: {list(SIGNALS)}")
|
|
self.signal = signal
|
|
self.fields, self._score = SIGNALS[signal]
|
|
self.top_n = top_n
|
|
self.rebal_freq = rebal_freq
|
|
self.buffer = buffer
|
|
self.warmup = warmup
|
|
|
|
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)
|
|
# names without a price are not eligible; a backtest also passes
|
|
# "eligible" (point-in-time index membership) so signals can use a
|
|
# stock's full price history while only members are picked
|
|
eligible = close.notna()
|
|
if "eligible" in bars:
|
|
eligible &= bars["eligible"].reindex_like(close).fillna(False).astype(bool)
|
|
score = score.where(eligible)
|
|
weights = select_with_buffer(score, self.top_n, self.rebal_freq, self.buffer, self.warmup,
|
|
prices=close)
|
|
return weights.shift(1).fillna(0.0)
|