Skip to content

Commit b7e3b7f

Browse files
committed
Professionalize factor model: trend/MR as filters, fix vol targeting
Based on quantitative review of the system: Factor model changes (quant/signals/factors.py): - Trend: converted from scored factor to post-composite filter (price < 200d SMA → score *= 0.5) to avoid momentum correlation - Blowoff filter: new post-composite filter penalizes stocks with Bollinger zscore > 3 (score *= 0.5) to avoid chasing parabolic moves - Mean reversion: disabled as scored factor (weight=0), replaced by blowoff filter for extreme protection Vol targeting fix (quant/strategy.py): - Removed renormalization after vol scaling — this was completely negating the vol target by scaling weights back to sum=1.0 - Now preserves cash buffer when portfolio vol exceeds target Config tuning (config.yaml): - momentum_windows: [63, 126, 252] — dropped noisy 1-month window - factor_weights: momentum 0.45, quality 0.25, vol 0.10, value 0.05 - max_positions: 18, stop_loss: 12%, sector cap: 40% - backtest start: 2016-01-01 (was 2022, too biased toward AI bull) - lookback_years: 5 (was 3) Tests: 44 pass (4 new for trend_filter + blowoff_filter) https://claude.ai/code/session_01LbgD64GPmP5tS7j4uavkCa
1 parent 8b05dc3 commit b7e3b7f

6 files changed

Lines changed: 108 additions & 36 deletions

File tree

config.yaml

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -532,34 +532,32 @@ universe:
532532
benchmark: SPY
533533

534534
data:
535-
lookback_years: 3
535+
lookback_years: 5
536536
frequency: 1d # daily bars
537537

538538
signals:
539-
# Momentum
540-
momentum_windows: [21, 63, 126, 252] # 1m, 3m, 6m, 12m in trading days
541-
# Mean reversion
539+
# Momentum — 3m/6m/12m; dropped 1m (too noisy)
540+
momentum_windows: [63, 126, 252] # 3m, 6m, 12m in trading days
541+
# Mean reversion — used as blowoff filter (zscore > 3 → penalize)
542542
mean_reversion_window: 20
543-
mean_reversion_zscore_threshold: 2.0
544-
# Value
545-
# (fundamentals fetched from yfinance)
546-
# Volatility
547-
volatility_window: 63
548-
# Trend
543+
mean_reversion_zscore_threshold: 3.0
544+
# Trend — used as filter: price < 200d SMA → score *= 0.5
549545
sma_short: 50
550546
sma_long: 200
551-
# Factor weights (must sum to 1.0) — adjust to change portfolio style
552-
# Higher momentum/trend = more growth/tech; higher value/volatility = more stable/defensive
547+
# Volatility
548+
volatility_window: 63
549+
# Factor weights (must sum to 1.0) — trend/mean_reversion now applied as filters, not scored
550+
# 因子权重(必须加总为 1.0)— trend 和 mean_reversion 改为过滤器,不参与评分
553551
factor_weights:
554-
momentum: 0.30
555-
mean_reversion: 0.10
556-
trend: 0.25
557-
volatility: 0.05
558-
value: 0.15
559-
quality: 0.15
552+
momentum: 0.45
553+
quality: 0.25
554+
volatility: 0.10
555+
value: 0.05
556+
mean_reversion: 0.00 # disabled as scored factor; used as blowoff filter instead
557+
trend: 0.00 # disabled as scored factor; used as 200d SMA filter instead
560558

561559
portfolio:
562-
max_positions: 20
560+
max_positions: 18
563561
max_position_weight: 0.10 # 10% max single position
564562
min_position_weight: 0.02 # 2% min
565563
target_volatility: 0.15 # 15% annualized
@@ -568,11 +566,11 @@ portfolio:
568566

569567
risk:
570568
max_drawdown_limit: 0.20 # 20% max drawdown halt
571-
max_sector_weight: 0.30 # 30% max in one sector
572-
stop_loss_pct: 0.08 # 8% individual stop loss
569+
max_sector_weight: 0.40 # 40% max in one sector (raised for tech-heavy pool)
570+
stop_loss_pct: 0.12 # 12% individual stop loss (wider for momentum strategy)
573571

574572
backtest:
575-
start_date: "2022-01-01"
573+
start_date: "2016-01-01"
576574
end_date: null # null = today
577575
initial_capital: 1000000
578576
slippage_bps: 5

quant/signals/factors.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,32 @@ def trend_factor(prices: pd.DataFrame, short_window: int = 50,
7171
return zs
7272

7373

74+
def trend_filter(prices: pd.DataFrame, long_window: int = 200,
75+
penalty: float = 0.5) -> pd.DataFrame:
76+
"""Trend filter: penalize stocks trading below their 200-day SMA.
77+
78+
Returns a DataFrame of multipliers (1.0 for uptrend, `penalty` for downtrend).
79+
Used as a post-composite filter rather than a scored factor.
80+
"""
81+
sma_long = prices.rolling(long_window).mean()
82+
above = prices >= sma_long
83+
return above.astype(float).replace(0.0, penalty)
84+
85+
86+
def blowoff_filter(prices: pd.DataFrame, window: int = 20,
87+
zscore_limit: float = 3.0, penalty: float = 0.5) -> pd.DataFrame:
88+
"""Penalize stocks with extreme short-term gains (blowoff top protection).
89+
90+
If a stock's Bollinger z-score exceeds `zscore_limit`, its composite score
91+
is multiplied by `penalty`. Prevents chasing parabolic moves.
92+
"""
93+
rolling_mean = prices.rolling(window).mean()
94+
rolling_std = prices.rolling(window).std()
95+
zscore = (prices - rolling_mean) / rolling_std
96+
overextended = zscore > zscore_limit
97+
return (~overextended).astype(float).replace(0.0, penalty)
98+
99+
74100
def volatility_factor(returns: pd.DataFrame, window: int = 63) -> pd.DataFrame:
75101
"""Realized volatility factor (low-vol anomaly: prefer lower vol).
76102
@@ -152,6 +178,13 @@ def generate(self, prices: pd.DataFrame, returns: pd.DataFrame,
152178
fundamentals: pd.DataFrame = None) -> pd.DataFrame:
153179
"""Produce a composite alpha score for each symbol on each date.
154180
181+
Pipeline:
182+
1. Compute scored factors (momentum, volatility, value, quality, etc.)
183+
2. Build weighted composite from scored factors
184+
3. Apply post-composite filters:
185+
- Trend filter: penalize stocks below 200d SMA
186+
- Blowoff filter: penalize stocks with extreme short-term gains
187+
155188
Returns DataFrame (dates x symbols) of composite z-scores.
156189
"""
157190
symbols = [c for c in prices.columns if c != self.benchmark]
@@ -182,17 +215,26 @@ def generate(self, prices: pd.DataFrame, returns: pd.DataFrame,
182215
df = df.reindex(columns=px.columns)
183216
factors[name] = df
184217

185-
# Weighted composite
218+
# Weighted composite (only factors with weight > 0 contribute)
186219
composite = pd.DataFrame(0.0, index=px.index, columns=px.columns)
187220
total_weight = 0.0
188221
for name, weight in self.weights.items():
189-
if name in factors:
222+
if weight > 0 and name in factors:
190223
f = factors[name].reindex(index=px.index, columns=px.columns)
191224
composite += weight * f.fillna(0)
192225
total_weight += weight
193226

194227
if total_weight > 0:
195228
composite /= total_weight
196229

230+
# --- Post-composite filters ---
231+
# Trend filter: penalize stocks below 200d SMA (score *= 0.5)
232+
tf = trend_filter(px, long_window=self.sma_long)
233+
composite = composite * tf.reindex(index=composite.index, columns=composite.columns).fillna(1.0)
234+
235+
# Blowoff filter: penalize stocks with extreme overbought z-score > threshold
236+
bf = blowoff_filter(px, window=self.mr_window, zscore_limit=self.mr_threshold)
237+
composite = composite * bf.reindex(index=composite.index, columns=composite.columns).fillna(1.0)
238+
197239
logger.info("Generated composite signal: shape=%s", composite.shape)
198240
return composite

quant/strategy.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,8 @@ def run_backtest(self, start: str = None, end: str = None) -> BacktestResult:
8686
selected.tolist(), day_scores, cov
8787
)
8888
weights = self.optimizer.apply_vol_scaling(weights, cov)
89-
90-
# Normalize back to sum=1 after vol scaling
91-
if weights.sum() > 0:
92-
weights /= weights.sum()
89+
# Do NOT renormalize after vol scaling — the remainder is held as cash.
90+
# This preserves the vol-targeting effect.
9391

9492
target_weights[str(date.date())] = weights
9593

@@ -151,8 +149,7 @@ def get_current_portfolio(self, capital: float = None) -> pd.DataFrame:
151149
# Optimize weights
152150
weights = self.optimizer.optimize_weights(symbols, day_scores, cov)
153151
weights = self.optimizer.apply_vol_scaling(weights, cov)
154-
if weights.sum() > 0:
155-
weights /= weights.sum()
152+
# Do NOT renormalize — remainder is cash buffer for vol targeting.
156153

157154
# Build output table
158155
latest_prices = prices[symbols].iloc[-1]

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ def config():
1616
},
1717
"data": {"lookback_years": 2, "frequency": "1d"},
1818
"signals": {
19-
"momentum_windows": [21, 63, 126],
19+
"momentum_windows": [63, 126, 252],
2020
"mean_reversion_window": 20,
21-
"mean_reversion_zscore_threshold": 2.0,
21+
"mean_reversion_zscore_threshold": 3.0,
2222
"volatility_window": 63,
2323
"sma_short": 50,
2424
"sma_long": 200,
@@ -34,7 +34,7 @@ def config():
3434
"risk": {
3535
"max_drawdown_limit": 0.20,
3636
"max_sector_weight": 0.30,
37-
"stop_loss_pct": 0.08,
37+
"stop_loss_pct": 0.12,
3838
},
3939
"backtest": {
4040
"start_date": "2020-01-01",

tests/test_factors.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import pytest
66

77
from quant.signals.factors import (
8-
momentum_factor, mean_reversion_factor, trend_factor,
9-
volatility_factor, value_factor, quality_factor, SignalGenerator,
8+
momentum_factor, mean_reversion_factor, trend_factor, trend_filter,
9+
blowoff_filter, volatility_factor, value_factor, quality_factor,
10+
SignalGenerator,
1011
)
1112

1213

@@ -59,6 +60,40 @@ def test_uptrend_positive(self):
5960
assert last["UP"] > last["DOWN"]
6061

6162

63+
class TestTrendFilter:
64+
def test_uptrend_no_penalty(self):
65+
"""Stocks above 200d SMA should get multiplier 1.0."""
66+
dates = pd.bdate_range("2020-01-01", periods=300)
67+
px = pd.DataFrame({"UP": np.linspace(50, 150, 300)}, index=dates)
68+
result = trend_filter(px, long_window=200)
69+
assert result["UP"].iloc[-1] == 1.0
70+
71+
def test_downtrend_penalized(self):
72+
"""Stocks below 200d SMA should get multiplier 0.5."""
73+
dates = pd.bdate_range("2020-01-01", periods=300)
74+
px = pd.DataFrame({"DOWN": np.linspace(150, 50, 300)}, index=dates)
75+
result = trend_filter(px, long_window=200)
76+
assert result["DOWN"].iloc[-1] == 0.5
77+
78+
79+
class TestBlowoffFilter:
80+
def test_normal_stock_no_penalty(self):
81+
"""Stock with moderate z-score should get multiplier 1.0."""
82+
dates = pd.bdate_range("2020-01-01", periods=50)
83+
px = pd.DataFrame({"A": np.linspace(100, 110, 50)}, index=dates)
84+
result = blowoff_filter(px, window=20, zscore_limit=3.0)
85+
assert result["A"].iloc[-1] == 1.0
86+
87+
def test_parabolic_stock_penalized(self):
88+
"""Stock with extreme spike should get multiplier 0.5."""
89+
dates = pd.bdate_range("2020-01-01", periods=50)
90+
# Flat then massive vertical spike — guarantees zscore >> 3
91+
prices = np.concatenate([np.full(45, 100), np.array([100, 100, 100, 100, 500])])
92+
px = pd.DataFrame({"A": prices}, index=dates)
93+
result = blowoff_filter(px, window=20, zscore_limit=3.0)
94+
assert result["A"].iloc[-1] == 0.5
95+
96+
6297
class TestVolatilityFactor:
6398
def test_low_vol_preferred(self, synthetic_returns):
6499
ret = synthetic_returns.drop(columns=["BENCH"])

tests/test_portfolio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def test_stop_loss(self, config):
5656
opt = PortfolioOptimizer(config)
5757
weights = pd.Series({"A": 0.5, "B": 0.5})
5858
entry = pd.Series({"A": 100.0, "B": 100.0})
59-
current = pd.Series({"A": 88.0, "B": 100.0}) # A dropped 12% > 8% stop
59+
current = pd.Series({"A": 85.0, "B": 100.0}) # A dropped 15% > 12% stop
6060
result = opt.check_stop_losses(weights, entry, current)
6161
assert result["A"] == 0.0
6262
assert result["B"] > 0.0

0 commit comments

Comments
 (0)