Files
quant-trading/strategies/composite_alpha.py
Gahow Wang 722cade974 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.
2026-10-01 02:24:01 -07:00

137 lines
5.6 KiB
Python

"""
Composite Alpha Strategy.
Combines the strongest alpha factors discovered in research:
1. Recovery (63d) - strongest single IC
2. Intermediate momentum (7m) - strong trend signal
3. Quality (consistency + low drawdown) - filters lottery tickets
4. MA200 trend confirmation - only stocks above their MA200
With:
- Inverse-vol weighting for risk parity among selected stocks
- SPY MA200 market regime gate (reduce exposure in bear markets)
- Biweekly rebalancing (compromise between signal freshness and turnover)
"""
import numpy as np
import pandas as pd
from strategies.base import StockStrategy, mask_scores
class CompositeAlphaStrategy(StockStrategy):
"""
Multi-factor alpha composite with regime gating.
"""
def __init__(
self,
tickers: list[str] | None = None,
benchmark: str = "SPY",
recovery_window: int = 63,
intermediate_period: int = 147,
skip: int = 21,
quality_window: int = 252,
vol_window: int = 60,
rebal_freq: int = 10,
top_n: int = 20,
regime_gate: bool = True,
regime_ma: int = 200,
):
self.tickers = tickers
self.benchmark = benchmark
self.recovery_window = recovery_window
self.intermediate_period = intermediate_period
self.skip = skip
self.quality_window = quality_window
self.vol_window = vol_window
self.rebal_freq = rebal_freq
self.top_n = top_n
self.regime_gate = regime_gate
self.regime_ma = regime_ma
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
if self.regime_gate and self.benchmark not in data.columns:
raise ValueError(f"Missing regime benchmark: {self.benchmark}")
# Separate benchmark from stocks if needed
if self.tickers is not None:
stock_data = data[[t for t in self.tickers if t in data.columns]]
else:
stock_cols = [c for c in data.columns if c != self.benchmark]
stock_data = data[stock_cols]
# --- Factor 1: Recovery ---
recovery = stock_data / stock_data.rolling(
self.recovery_window, min_periods=self.recovery_window
).min() - 1
# --- Factor 2: Intermediate momentum (7-month, skip 1 month) ---
int_mom = stock_data.shift(self.skip).pct_change(self.intermediate_period - self.skip, fill_method=None)
# --- Factor 3: Quality composite ---
# Consistency: fraction of positive 21-day returns
monthly_ret = stock_data.pct_change(21, fill_method=None)
consistency = (monthly_ret > 0).astype(float).rolling(
self.quality_window, min_periods=self.quality_window // 2
).mean()
# Up-volume proxy: sum of positive daily returns over 20 days
daily_ret = stock_data.pct_change(fill_method=None)
up_vol_proxy = daily_ret.where(daily_ret > 0, 0).rolling(20, min_periods=15).sum()
# --- Factor 4: Above MA200 (per-stock trend filter) ---
ma200 = stock_data.rolling(200, min_periods=200).mean()
above_ma = (stock_data > ma200)
# --- Cross-sectional ranks ---
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
mom_rank = mask_scores(int_mom, eligible).rank(axis=1, pct=True, na_option="keep")
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
upv_rank = mask_scores(up_vol_proxy, eligible).rank(axis=1, pct=True, na_option="keep")
# Composite: recovery 30%, int_momentum 30%, consistency 20%, up_volume 20%
composite = (0.30 * rec_rank + 0.30 * mom_rank +
0.20 * con_rank + 0.20 * upv_rank)
# Apply per-stock MA200 filter: must be in uptrend
composite = composite.where(above_ma, np.nan)
# --- Select top_n ---
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
composite = mask_scores(composite, eligible)
n_valid = composite.notna().sum(axis=1)
enough = n_valid >= min(self.top_n, 5)
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
# --- Inverse-vol weighting ---
vol = daily_ret.rolling(self.vol_window, min_periods=30).std().replace(0, np.nan)
inv_vol = (1.0 / vol).where(top_mask, 0.0)
row_sums = inv_vol.sum(axis=1).replace(0, np.nan)
signals = inv_vol.div(row_sums, axis=0).fillna(0.0)
# --- Market regime gate (SPY > MA200) ---
if self.regime_gate and self.benchmark in data.columns:
spy = data[self.benchmark]
spy_ma = spy.rolling(self.regime_ma, min_periods=self.regime_ma).mean()
market_bull = (spy > spy_ma).astype(float)
# Partial scaling: 100% when bullish, 30% when bearish (don't go fully to cash)
regime_scale = market_bull * 0.7 + 0.3
signals = signals.mul(regime_scale, axis=0)
# --- Biweekly rebalance ---
warmup = max(self.quality_window, 200, self.intermediate_period) + self.skip
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
# Align to full data columns (in case benchmark is in data)
full_signals = pd.DataFrame(0.0, index=data.index, columns=data.columns)
for col in signals.columns:
if col in full_signals.columns:
full_signals[col] = signals[col]
return full_signals.shift(1).fillna(0.0)