Add 12 strategy modules including adaptive blend, composite alpha, cross-asset momentum, ensemble alpha, trend rider v5/v6, and more.
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""
|
|
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
|