Initial commit: quant backtesting framework with daily trading simulator
Backtesting engine supporting 11 strategies across US (S&P 500) and CN (CSI 300) markets with open-to-close execution, proportional + fixed per-trade fees. Daily trader (trader.py) with auto/morning/evening/simulate/status commands and cron-friendly `auto` mode for unattended daily runs on a server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
60
strategies/adaptive_momentum.py
Normal file
60
strategies/adaptive_momentum.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
|
||||
|
||||
class AdaptiveMomentumStrategy(Strategy):
|
||||
"""
|
||||
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) -> pd.DataFrame:
|
||||
# Momentum ranking
|
||||
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip)
|
||||
|
||||
n_valid = momentum.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
|
||||
score_rank = momentum.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()
|
||||
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 = vol.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)
|
||||
Reference in New Issue
Block a user