fix: unify backtest execution and add forward validation candidates
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.
This commit is contained in:
@@ -19,14 +19,14 @@ New approach:
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
def _rank(df):
|
||||
return df.rank(axis=1, pct=True, na_option="keep")
|
||||
def _rank(df, eligible=None):
|
||||
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
|
||||
class EnsembleAlphaStrategy(Strategy):
|
||||
class EnsembleAlphaStrategy(StockStrategy):
|
||||
"""
|
||||
Ensemble of the two strongest signals with tail-risk protection.
|
||||
"""
|
||||
@@ -45,28 +45,28 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
self.tail_threshold = tail_threshold
|
||||
self.tail_scale = tail_scale
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> 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)
|
||||
mom_filter = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
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)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
|
||||
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)
|
||||
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
|
||||
|
||||
rec_63_r = _rank(rec_63)
|
||||
mom_r = _rank(mom_12_1)
|
||||
rec_63_r = _rank(rec_63, eligible)
|
||||
mom_r = _rank(mom_12_1, eligible)
|
||||
|
||||
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
|
||||
|
||||
@@ -74,7 +74,9 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
ensemble = 0.5 * signal_a + 0.5 * signal_b
|
||||
|
||||
# === Select top_n ===
|
||||
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = ensemble.where(data.notna())
|
||||
rank = mask_scores(ensemble, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = mask_scores(ensemble, eligible)
|
||||
n_valid = ensemble.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -99,7 +101,7 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
# otherwise daily flips force half-out/half-in trades that burn
|
||||
# the account through fixed per-trade fees.
|
||||
if self.tail_protection:
|
||||
mkt_ret = ret.mean(axis=1, skipna=True)
|
||||
mkt_ret = mask_scores(ret, eligible).mean(axis=1, skipna=True)
|
||||
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
|
||||
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
||||
in_tail = mkt_dd < self.tail_threshold
|
||||
@@ -111,7 +113,7 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
return signals.shift(1).fillna(0.0)
|
||||
|
||||
|
||||
class EnhancedFactorComboStrategy(Strategy):
|
||||
class EnhancedFactorComboStrategy(StockStrategy):
|
||||
"""
|
||||
FactorCombo signal enhanced with:
|
||||
1. Additional momentum confirmation (12-1 momentum rank as tiebreaker)
|
||||
@@ -131,31 +133,33 @@ class EnhancedFactorComboStrategy(Strategy):
|
||||
self.mom_boost = mom_boost
|
||||
self.tail_protection = tail_protection
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> 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)
|
||||
mom_filter = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
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)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
|
||||
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)
|
||||
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
|
||||
mom_r = _rank(mom_12_1, eligible)
|
||||
|
||||
# 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")
|
||||
signal = signal.where(data.notna())
|
||||
rank = mask_scores(signal, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
signal = mask_scores(signal, eligible)
|
||||
n_valid = signal.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -177,7 +181,7 @@ class EnhancedFactorComboStrategy(Strategy):
|
||||
|
||||
# Tail protection (rebal-gated to avoid daily-flip turnover)
|
||||
if self.tail_protection:
|
||||
mkt_ret = ret.mean(axis=1, skipna=True)
|
||||
mkt_ret = mask_scores(ret, eligible).mean(axis=1, skipna=True)
|
||||
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
|
||||
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
||||
in_tail = mkt_dd < -0.15
|
||||
@@ -189,7 +193,7 @@ class EnhancedFactorComboStrategy(Strategy):
|
||||
return signals.shift(1).fillna(0.0)
|
||||
|
||||
|
||||
class RiskManagedEnsembleStrategy(Strategy):
|
||||
class RiskManagedEnsembleStrategy(StockStrategy):
|
||||
"""
|
||||
EnsembleAlpha with market-aware drawdown risk management.
|
||||
|
||||
@@ -230,15 +234,15 @@ class RiskManagedEnsembleStrategy(Strategy):
|
||||
self.vol_spike_lookback = vol_spike_lookback
|
||||
self.vol_spike_floor = vol_spike_floor
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Step 1: Get raw signals from the ensemble (already shifted by 1)
|
||||
raw = self.ensemble.generate_signals(data)
|
||||
raw = self.ensemble.generate_signals(data, eligible=eligible)
|
||||
|
||||
# Step 2: Compute MARKET returns over valid (non-masked) columns.
|
||||
# `daily_rets` keeps NaN for PIT-masked tickers so they don't dilute
|
||||
# the cross-sectional mean to ~0.
|
||||
daily_rets = data.pct_change()
|
||||
mkt_rets = daily_rets.mean(axis=1, skipna=True)
|
||||
daily_rets = data.pct_change(fill_method=None)
|
||||
mkt_rets = mask_scores(daily_rets, eligible).mean(axis=1, skipna=True)
|
||||
|
||||
# Step 3: Market drawdown dampener
|
||||
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
|
||||
@@ -272,7 +276,7 @@ class RiskManagedEnsembleStrategy(Strategy):
|
||||
return raw.mul(final_scale, axis=0)
|
||||
|
||||
|
||||
class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
class SharpeBoostedEnsembleStrategy(StockStrategy):
|
||||
"""
|
||||
Optimized ensemble targeting Sharpe >1.5 while maintaining high CAGR.
|
||||
|
||||
@@ -310,33 +314,35 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
self.dd_floor = dd_floor
|
||||
self.dd_denom = dd_denom
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
p = data
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
|
||||
# === 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)
|
||||
mom_filter = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
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)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
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)
|
||||
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
|
||||
rec_63_r = _rank(rec_63, eligible)
|
||||
mom_r = _rank(mom_12_1, eligible)
|
||||
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")
|
||||
ensemble = ensemble.where(data.notna())
|
||||
rank = mask_scores(ensemble, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = mask_scores(ensemble, eligible)
|
||||
n_valid = ensemble.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -361,7 +367,7 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
# burning the account through fixed per-trade fees ($2 US / $5 CN).
|
||||
# Use cross-sectional mean of non-masked returns so PIT-masked
|
||||
# NaN→0 fills don't dilute the market signal.
|
||||
daily_rets = data.pct_change()
|
||||
daily_rets = data.pct_change(fill_method=None)
|
||||
port_rets = (signals * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
|
||||
short_vol = port_rets.rolling(20, min_periods=10).std() * np.sqrt(252)
|
||||
vol_median = short_vol.rolling(252, min_periods=126).median()
|
||||
@@ -371,7 +377,7 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
asym_scale_raw[high_vol_neg] = self.asym_vol_floor
|
||||
|
||||
# === Market-DD dampener (rebal-gated, NaN-aware market mean) ===
|
||||
mkt_rets = daily_rets.mean(axis=1, skipna=True)
|
||||
mkt_rets = mask_scores(daily_rets, eligible).mean(axis=1, skipna=True)
|
||||
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
|
||||
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
||||
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(
|
||||
|
||||
Reference in New Issue
Block a user