Files
quant-trading/strategies/adaptive_momentum.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

62 lines
2.5 KiB
Python

import numpy as np
import pandas as pd
from strategies.base import StockStrategy, mask_scores
class AdaptiveMomentumStrategy(StockStrategy):
"""
Momentum with dynamic position sizing via inverse-volatility weighting.
Combines two proven ideas:
1. Momentum selection: pick top_n stocks by 12-1 month return
2. Inverse-vol weighting: instead of equal-weight, allocate more
to lower-vol winners (smoother ride, better risk-adjusted returns)
3. Regime scaling: scale total exposure by inverse of market vol
(reduce exposure in high-vol periods, increase in calm periods)
This addresses momentum's main weakness: concentration in high-vol
names that crash hard in risk-off episodes.
"""
def __init__(self, lookback: int = 252, skip: int = 21,
vol_window: int = 60, top_n: int = 20):
self.lookback = lookback
self.skip = skip
self.vol_window = vol_window
self.top_n = top_n
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
# Momentum ranking
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip, fill_method=None)
momentum = mask_scores(momentum, eligible)
n_valid = momentum.notna().sum(axis=1)
enough = n_valid >= self.top_n
score_rank = mask_scores(momentum, eligible).rank(axis=1, ascending=False, na_option="bottom")
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
# Inverse-vol weighting among selected stocks
returns = data.pct_change(fill_method=None)
vol = returns.rolling(self.vol_window).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)
# Regime scaling: compare current market vol to its 1-year median
market_vol = mask_scores(vol, eligible).mean(axis=1)
vol_median = market_vol.rolling(252).median()
vol_scale = (vol_median / market_vol).clip(0.3, 1.5)
signals = signals.mul(vol_scale, axis=0)
# Re-normalize so max total weight = 1.0
row_totals = signals.sum(axis=1)
overflow = row_totals > 1.0
signals[overflow] = signals[overflow].div(row_totals[overflow], axis=0)
warmup = max(self.lookback, self.vol_window + 252)
signals.iloc[:warmup] = 0.0
return signals.shift(1).fillna(0.0)