Skip to main content

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 spikesGoes flat during VIX spikes
Crowded with retail mean-rev traders in calm marketsSame
~30% turnover/yrSlightly higher turnover (mask transitions)
Sharpe 0.9Sharpe 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.

See also