feat: add new trading strategies

Add 12 strategy modules including adaptive blend, composite alpha,
cross-asset momentum, ensemble alpha, trend rider v5/v6, and more.
This commit is contained in:
2026-05-14 12:53:09 +08:00
parent 140f0695d0
commit d086930ab3
12 changed files with 2973 additions and 0 deletions

View File

@@ -0,0 +1,367 @@
"""
Round 3: Signal-level ensemble of the two best strategies.
Key insight from R1/R2:
- FactorCombo rec_mfilt+deep_upvol: CAGR 34.6%, MaxDD -33.9%, Calmar 1.02
- Recovery+Mom Top20: CAGR 34.5%, MaxDD -37.7%, Calmar 0.91
- Inv-vol weighting HURTS recovery signals (they're high-vol by nature)
- More factors = more noise for this alpha source
- Monthly rebalancing is optimal
New approach:
1. Ensemble the two best SIGNALS (not strategies) at the rank level
→ diversifies stock picks while preserving signal strength
2. Equal weighting (proven better for recovery-type signals)
3. Tail-risk protection: only scale down in EXTREME drawdown regimes
(>15% drawdown from peak), not regular vol spikes
4. Test whether a 126-day recovery (deeper) adds signal vs 63-day
"""
import numpy as np
import pandas as pd
from strategies.base import Strategy
def _rank(df):
return df.rank(axis=1, pct=True, na_option="keep")
class EnsembleAlphaStrategy(Strategy):
"""
Ensemble of the two strongest signals with tail-risk protection.
"""
def __init__(
self,
rebal_freq: int = 21,
top_n: int = 20,
tail_protection: bool = True,
tail_threshold: float = -0.15, # drawdown level to trigger protection
tail_scale: float = 0.5, # how much to reduce in tail event
):
self.rebal_freq = rebal_freq
self.top_n = top_n
self.tail_protection = tail_protection
self.tail_threshold = tail_threshold
self.tail_scale = tail_scale
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
p = data
# === Signal A: rec_mfilt + deep_upvol (from FactorCombo) ===
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
mom_filter = p.shift(21).pct_change(105)
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
ret = p.pct_change()
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
signal_a = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# === Signal B: Recovery 63d + 12-1 momentum (from RecoveryMom) ===
rec_63 = p / p.rolling(63, min_periods=63).min() - 1
mom_12_1 = p.shift(21).pct_change(231)
rec_63_r = _rank(rec_63)
mom_r = _rank(mom_12_1)
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
# === Ensemble: average of both signals ===
ensemble = 0.5 * signal_a + 0.5 * signal_b
# === Select top_n ===
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
n_valid = ensemble.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
# Equal weight (proven better for recovery signals)
raw = top_mask.astype(float)
row_sums = raw.sum(axis=1).replace(0, np.nan)
signals = raw.div(row_sums, axis=0).fillna(0.0)
# === Tail-risk protection ===
if self.tail_protection:
# Portfolio equity proxy: equal-weight market return
mkt_ret = ret.mean(axis=1)
mkt_eq = (1 + mkt_ret).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < self.tail_threshold
scale = pd.Series(1.0, index=data.index)
scale[in_tail] = self.tail_scale
signals = signals.mul(scale, axis=0)
# === Monthly rebalance ===
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
rebal_indices = list(range(warmup, len(data), self.rebal_freq))
rebal_mask.iloc[rebal_indices] = True
signals[~rebal_mask] = np.nan
signals = signals.ffill().fillna(0.0)
signals.iloc[:warmup] = 0.0
return signals.shift(1).fillna(0.0)
class EnhancedFactorComboStrategy(Strategy):
"""
FactorCombo signal enhanced with:
1. Additional momentum confirmation (12-1 momentum rank as tiebreaker)
2. Concentration in top conviction names (top_n=15 instead of 20)
3. Optional tail protection
"""
def __init__(
self,
rebal_freq: int = 21,
top_n: int = 15,
mom_boost: float = 0.2, # weight given to additional momentum signal
tail_protection: bool = False,
):
self.rebal_freq = rebal_freq
self.top_n = top_n
self.mom_boost = mom_boost
self.tail_protection = tail_protection
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
p = data
# Core signal: rec_mfilt + deep_upvol
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
mom_filter = p.shift(21).pct_change(105)
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
ret = p.pct_change()
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
base_signal = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# Momentum boost: 12-1 month return rank
mom_12_1 = p.shift(21).pct_change(231)
mom_r = _rank(mom_12_1)
# Combined: base + momentum tiebreaker
signal = (1 - self.mom_boost) * base_signal + self.mom_boost * mom_r
# Select top_n
rank = signal.rank(axis=1, ascending=False, na_option="bottom")
n_valid = signal.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
# Equal weight
raw = top_mask.astype(float)
row_sums = raw.sum(axis=1).replace(0, np.nan)
signals = raw.div(row_sums, axis=0).fillna(0.0)
# Tail protection
if self.tail_protection:
mkt_ret = ret.mean(axis=1)
mkt_eq = (1 + mkt_ret).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
in_tail = mkt_dd < -0.15
scale = pd.Series(1.0, index=data.index)
scale[in_tail] = 0.5
signals = signals.mul(scale, axis=0)
# Monthly rebalance
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
rebal_indices = list(range(warmup, len(data), self.rebal_freq))
rebal_mask.iloc[rebal_indices] = True
signals[~rebal_mask] = np.nan
signals = signals.ffill().fillna(0.0)
signals.iloc[:warmup] = 0.0
return signals.shift(1).fillna(0.0)
class RiskManagedEnsembleStrategy(Strategy):
"""
EnsembleAlpha with market-aware drawdown risk management.
Key insight: Using the strategy's OWN drawdown to scale down creates
a negative feedback loop (cut → miss rebound → deeper DD → cut more).
Instead, use MARKET drawdown as the systemic risk signal:
- Market crash → reduce exposure (systemic risk)
- Strategy underperforms but market is fine → stay invested (alpha issue, not risk)
Mechanisms:
1. Market DD dampener: scales down proportionally to equal-weight market drawdown.
Only fires during systemic stress. Recovers as market recovers.
2. Vol spike guard: when 10-day portfolio vol > 90th percentile of history,
reduce to vol_spike_floor. Catches acute crises.
Both use lagged (T-1) estimates → PIT-safe.
Parameter choices justified by market microstructure (not optimized):
- dd_denom=0.20 → at 20% market crash, exposure reduced to floor
- dd_floor=0.40 → never go below 40% (still participate in recovery)
- vol_spike_floor=0.50 → during vol spikes, halve exposure
"""
def __init__(
self,
top_n: int = 10,
dd_floor: float = 0.40,
dd_denom: float = 0.20,
vol_spike_guard: bool = True,
vol_spike_window: int = 10,
vol_spike_lookback: int = 252,
vol_spike_floor: float = 0.50,
):
self.ensemble = EnsembleAlphaStrategy(top_n=top_n, tail_protection=False)
self.dd_floor = dd_floor
self.dd_denom = dd_denom
self.vol_spike_guard = vol_spike_guard
self.vol_spike_window = vol_spike_window
self.vol_spike_lookback = vol_spike_lookback
self.vol_spike_floor = vol_spike_floor
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
# Step 1: Get raw signals from the ensemble (already shifted by 1)
raw = self.ensemble.generate_signals(data)
# Step 2: Compute MARKET returns (equal-weight of all stocks)
daily_rets = data.pct_change().fillna(0.0)
mkt_rets = daily_rets.mean(axis=1)
# Step 3: Market drawdown dampener
mkt_eq = (1 + mkt_rets).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1 # always ≤ 0
# Linear: at DD=0 → 1.0, at DD=-dd_denom → dd_floor
dd_scale = (1.0 + mkt_dd / self.dd_denom).clip(lower=self.dd_floor, upper=1.0)
dd_scale_lagged = dd_scale.shift(1).fillna(1.0) # PIT
# Step 4: Vol spike guard (uses portfolio's own vol for specificity)
if self.vol_spike_guard:
port_rets = (raw * daily_rets).sum(axis=1)
short_vol = port_rets.rolling(self.vol_spike_window, min_periods=5).std() * np.sqrt(252)
vol_90th = short_vol.rolling(self.vol_spike_lookback, min_periods=126).quantile(0.90)
in_spike = short_vol > vol_90th
vol_scale = pd.Series(1.0, index=data.index)
vol_scale[in_spike] = self.vol_spike_floor
vol_scale_lagged = vol_scale.shift(1).fillna(1.0) # PIT
else:
vol_scale_lagged = 1.0
# Step 5: Combined scaling
final_scale = dd_scale_lagged * vol_scale_lagged
return raw.mul(final_scale, axis=0)
class SharpeBoostedEnsembleStrategy(Strategy):
"""
Optimized ensemble targeting Sharpe >1.5 while maintaining high CAGR.
Key improvements over EnsembleAlphaStrategy:
1. Bimonthly rebalance (42d): recovery signals have 126-day lookback,
monthly rebal causes unnecessary turnover. Let winners run.
2. Slightly wider basket (top_n=12): diversifies idiosyncratic risk
without diluting alpha (sweet spot between 10-15).
3. Asymmetric vol scaling: only de-risk in high-vol NEGATIVE return
regimes (high-vol + positive = good, don't cut).
4. Light market-DD dampener: only fires in severe systemic stress
(dd_denom=0.35 → need 35% market crash to reach floor).
PIT compliance:
- All signal lookbacks use .shift(21) or rolling windows (no current-day data)
- Asymmetric vol uses .shift(1) on scale
- DD dampener uses .shift(1) on mkt_dd
- Final signals use .shift(1) for execution lag
Parameter count: 4 meaningful (rebal_freq, top_n, asym_vol_floor, dd_denom)
All have economic justification, not optimized on in-sample.
"""
def __init__(
self,
top_n: int = 12,
rebal_freq: int = 42,
asym_vol_floor: float = 0.50,
dd_floor: float = 0.70,
dd_denom: float = 0.35,
):
self.top_n = top_n
self.rebal_freq = rebal_freq
self.asym_vol_floor = asym_vol_floor
self.dd_floor = dd_floor
self.dd_denom = dd_denom
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
p = data
ret = p.pct_change()
# === Signal A: rec_mfilt + deep_upvol ===
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
mom_filter = p.shift(21).pct_change(105)
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
rec_mfilt_r = _rank(rec_mfilt)
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
deep_upvol = _rank(rec_126) * _rank(up_vol)
deep_upvol_r = _rank(deep_upvol)
signal_a = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
# === Signal B: Recovery 63d + 12-1 momentum ===
rec_63 = p / p.rolling(63, min_periods=63).min() - 1
mom_12_1 = p.shift(21).pct_change(231)
rec_63_r = _rank(rec_63)
mom_r = _rank(mom_12_1)
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
# === Ensemble: equal-weight average of both signals ===
ensemble = 0.5 * signal_a + 0.5 * signal_b
# === Select top_n ===
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
n_valid = ensemble.notna().sum(axis=1)
enough = n_valid >= self.top_n
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
raw = top_mask.astype(float)
row_sums = raw.sum(axis=1).replace(0, np.nan)
signals = raw.div(row_sums, axis=0).fillna(0.0)
# === Bimonthly rebalance (42 trading days) ===
warmup = 252
rebal_mask = pd.Series(False, index=data.index)
rebal_indices = list(range(warmup, len(data), self.rebal_freq))
rebal_mask.iloc[rebal_indices] = True
signals[~rebal_mask] = np.nan
signals = signals.ffill().fillna(0.0)
signals.iloc[:warmup] = 0.0
signals = signals.shift(1).fillna(0.0) # PIT: 1-day execution lag
# === Asymmetric vol scaling ===
# Only reduce exposure when vol is high AND returns are negative
# High vol + positive returns = riding a trend, don't cut
daily_rets = data.pct_change().fillna(0.0)
port_rets = (signals * daily_rets).sum(axis=1)
short_vol = port_rets.rolling(20, min_periods=10).std() * np.sqrt(252)
vol_median = short_vol.rolling(252, min_periods=126).median()
recent_ret = port_rets.rolling(20, min_periods=10).sum()
high_vol_neg = (short_vol > vol_median * 1.5) & (recent_ret < 0)
asym_scale = pd.Series(1.0, index=data.index)
asym_scale[high_vol_neg] = self.asym_vol_floor
signals = signals.mul(asym_scale.shift(1).fillna(1.0), axis=0) # PIT
# === Light market-DD dampener ===
# Uses market (not strategy) drawdown to avoid negative feedback loop
mkt_rets = daily_rets.mean(axis=1)
mkt_eq = (1 + mkt_rets).cumprod()
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
dd_scale = (1.0 + mkt_dd / self.dd_denom).clip(
lower=self.dd_floor, upper=1.0
)
signals = signals.mul(dd_scale.shift(1).fillna(1.0), axis=0) # PIT
return signals