Price Momentum Strategy¶
The classic momentum strategy based on past price returns.
Strategy Overview¶
Buy stocks with strong recent performance, short stocks with weak performance.
The Signal¶
12-1 Momentum¶
The most common momentum signal uses 12-month returns, skipping the most recent month:
Text Only
signal momentum_12_1:
// 12-month return
ret_12m = ret(prices, 252)
// Skip last month (1-month reversal effect)
ret_1m = ret(prices, 21)
// Net momentum
momentum = ret_12m - ret_1m
emit zscore(momentum)
Why Skip Last Month?¶
Short-term reversals contaminate momentum: - Stocks up recently tend to reverse in the next month - Skipping avoids this "mean reversion" noise
Complete Strategy¶
Text Only
data:
source = "prices_with_sectors.parquet"
format = parquet
// Momentum signal
signal momentum:
ret_12m = ret(prices, 252)
ret_1m = ret(prices, 21)
raw = ret_12m - ret_1m
// Sector neutralize to avoid sector bets
emit neutralize(zscore(raw), by=sectors)
// Portfolio
portfolio price_momentum:
weights = rank(momentum).long_short(
top = 0.2,
bottom = 0.2,
cap = 0.03
)
constraints:
gross_exposure = 2.0
net_exposure = 0.0
max_sector = 0.20
costs = tc.bps(10)
backtest rebal=21 from 2010-01-01 to 2024-12-31
Variations¶
Different Lookback Periods¶
Text Only
// Shorter momentum (faster signal)
signal momentum_6_1:
ret_6m = ret(prices, 126)
ret_1m = ret(prices, 21)
emit zscore(ret_6m - ret_1m)
// Longer momentum (slower signal)
signal momentum_18_1:
ret_18m = ret(prices, 378)
ret_1m = ret(prices, 21)
emit zscore(ret_18m - ret_1m)
Risk-Adjusted Momentum¶
Text Only
signal sharpe_momentum:
// Risk-adjusted returns
ret_12m = ret(prices, 252)
vol_12m = rolling_std(ret(prices, 1), 252) * sqrt(252)
sharpe = ret_12m / vol_12m
emit zscore(sharpe)
Residual Momentum¶
Text Only
signal residual_momentum:
// Market-adjusted returns
stock_ret = ret(prices, 252)
market_ret = ret(market, 252)
beta = rolling_cov(ret(prices, 1), ret(market, 1), 60) /
rolling_var(ret(market, 1), 60)
// Residual return
residual = stock_ret - beta * market_ret
emit zscore(residual)
Parameter Sensitivity¶
Text Only
params:
lookback: [126, 189, 252, 315] // 6, 9, 12, 15 months
skip: [0, 21, 42] // 0, 1, 2 months
signal momentum:
ret_full = ret(prices, lookback)
ret_skip = where(skip > 0, ret(prices, skip), 0)
emit zscore(ret_full - ret_skip)
portfolio optimized:
weights = rank(momentum).long_short(top=0.2, bottom=0.2)
backtest walk_forward(train_years=5, test_years=1) from 2010-01-01 to 2024-12-31
Expected Results¶
Text Only
Backtest Results: price_momentum
================================
Period: 2010-01-01 to 2024-12-31
Returns:
Total Return: 156%
Annual Return: 6.8%
Annual Volatility: 10.2%
Sharpe Ratio: 0.67
Risk:
Max Drawdown: -22.4%
Avg Drawdown: -5.8%
Turnover:
Annual Turnover: 285%
Avg Holding Period: 64 days
Risk Considerations¶
Momentum Crashes¶
Momentum can reverse sharply at market turning points:
Text Only
// Add regime filter
signal trend_filter:
ma_50 = rolling_mean(market, 50)
ma_200 = rolling_mean(market, 200)
bull = ma_50 > ma_200
emit bull
signal safe_momentum:
mom = momentum
// Reduce in bear markets
scale = where(trend_filter, 1.0, 0.5)
emit mom * scale
High Volatility Periods¶
Text Only
// Scale down when volatility spikes
signal vol_scaled_momentum:
mom = momentum
vix_high = vix > 25
scale = where(vix_high, 0.7, 1.0)
emit mom * scale
Enhancements¶
Combine with Other Factors¶
Text Only
signal enhanced_momentum:
mom = momentum
qual = zscore(roe) // Quality filter
// Only momentum in quality stocks
emit where(qual > 0, mom, mom * 0.5)