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:
2026-10-01 02:24:01 -07:00
parent 761725d2c8
commit 722cade974
45 changed files with 1561 additions and 1021 deletions

View File

@@ -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(