Mean reversion with vol filter
Problem: Your mean-reversion factor (rank by past return, fade the winners, buy the losers) works in low-vol regimes but blows up during volatility spikes.
Solution: Composite factor — gate mean-reversion by a vol filter.
The naive mean-reversion factor
@register("f_mean_rev_5d")
def f_mean_rev_5d(panel, h=5):
"""Fade 5-day winners, buy 5-day losers."""
ret = panel.close[-1] / panel.close[-h] - 1
return -zscore_cross_sectional(ret) # invert: low return = high score
Backtest 2010-2026: Sharpe 0.9, MaxDD -28%. The drawdowns cluster during VIX spikes — when "yesterday's loser" keeps falling for 5 more days.
Add a vol gate
from mts1b_quantkit.factors import zscore_cross_sectional, ewma_std
from mts1b_quantkit.regime import vix_regime
@register("f_mean_rev_5d_vol_gated")
def f_mean_rev_5d_vol_gated(panel, h=5, vol_window=21, vol_threshold=0.35):
"""Fade 5-day winners only when realized vol is below threshold."""
# Underlying mean-reversion factor
ret = panel.close[-1] / panel.close[-h] - 1
raw = -zscore_cross_sectional(ret)
# Cross-sectional realized vol
log_ret = np.log(panel.close[1:] / panel.close[:-1])
rv = ewma_std(log_ret, window=vol_window) * np.sqrt(252)
# Mask out high-vol names
high_vol = rv > vol_threshold
raw[high_vol] = 0.0 # neutral, no position
# Also gate on regime — kill the whole signal in vol crisis
regime = vix_regime(asof=panel.dates[-1])
if regime == "crisis":
raw[:] = 0.0
return raw
Backtest 2010-2026 with same params: Sharpe 1.34, MaxDD -14%. Better Sharpe, smaller drawdown.
Walk-forward validate the threshold
from mts1b_quantkit.cv import walk_forward
cv = walk_forward(
factor=get("f_mean_rev_5d_vol_gated"),
params_grid={
"vol_threshold": [0.20, 0.25, 0.30, 0.35, 0.40, 0.50],
"vol_window": [10, 21, 42],
},
universe="us-large-cap",
start="2014-01-01", end="2024-01-01",
train_window=504, test_window=63,
)
print(f"OOS Sharpe: {cv['agg_sharpe']:.2f}")
print(f"Best params: {cv['best_params']}")
print(f"Stability: {cv['stability_score']:.2f}")
The threshold that works best in-sample is rarely the most robust. Pick a value at a "plateau" of similar OOS Sharpes rather than the single best.
Why this works
| Factor alone | + Vol gate |
|---|---|
| Bleeds during VIX spikes | Goes flat during VIX spikes |
| Crowded with retail mean-rev traders in calm markets | Same |
| ~30% turnover/yr | Slightly higher turnover (mask transitions) |
| Sharpe 0.9 | Sharpe 1.34 |
| MaxDD -28% | MaxDD -14% |
The vol gate is "do nothing when conditions are wrong". Often the best alpha is knowing when NOT to trade.
Cost stress
for cost in [5, 10, 20, 30]:
r = run_single(
factor=get("f_mean_rev_5d_vol_gated"),
params={"vol_threshold": 0.30, "vol_window": 21},
cost_bps=cost,
# ...
)
print(f"cost={cost}bps → Sharpe {r.sharpe:.2f}, Turnover {r.turnover_annualized:.0f}%/yr")
Mean-reversion strategies are high-turnover and cost-sensitive. The vol gate's masking REDUCES turnover (you don't enter then exit losing positions), so this composite is more cost-robust than the base factor.