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

@@ -1,9 +1,9 @@
import numpy as np
import pandas as pd
from strategies.base import Strategy
from strategies.base import StockStrategy, mask_scores
class MultiFactorStrategy(Strategy):
class MultiFactorStrategy(StockStrategy):
"""
Multi-factor strategy combining momentum and value signals with a market-timing filter.
@@ -33,7 +33,7 @@ class MultiFactorStrategy(Strategy):
self.value_period = value_period
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
stock = data[self.tickers]
# --- Market timing filter ---
@@ -42,7 +42,7 @@ class MultiFactorStrategy(Strategy):
# --- Momentum factor ---
# Return from T-(momentum_period+skip) to T-skip, avoiding the last month
momentum = stock.shift(self.momentum_skip).pct_change(self.momentum_period)
momentum = stock.shift(self.momentum_skip).pct_change(self.momentum_period, fill_method=None)
# --- Value factor ---
# min_price_over_period / current_price (higher = more "undervalued" vs recent range)
@@ -51,16 +51,17 @@ class MultiFactorStrategy(Strategy):
# --- Cross-sectional ranking (each row ranked across assets) ---
# na_option="keep" so NaN stocks (pre-IPO / delisted / masked) stay
# NaN in the composite score instead of being assigned pct=1.0.
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
val_rank = value.rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
val_rank = mask_scores(value, eligible).rank(axis=1, pct=True, na_option="keep")
scores = mom_rank + val_rank # combined score, higher = better
# --- Select top_n assets per row ---
# Only allocate rows that have enough valid scores
scores = mask_scores(scores, eligible)
n_valid = scores.notna().sum(axis=1)
enough_data = n_valid >= self.top_n
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough_data.values.reshape(-1, 1)
# Equal-weight allocation among selected assets