Regime detection + gating
Problem: Your strategy crushes it in normal markets but bleeds in crises. How do you detect a regime change and stop trading?
Solution: Build a regime classifier; use it as a gate on your factor signals.
Common regime types
| Regime | Signal |
|---|---|
| Bull | SPY > 200-DMA, VIX < 20 |
| Chop | SPY ~ 200-DMA, 20-DMA std > N-DMA mean |
| Crash | SPY < 200-DMA, VIX > 30 |
| High-vol | VIX > 35 |
| Low-vol | VIX < 12 |
These are heuristics. The mts1b_quantkit.regime module has empirically-tuned thresholds.
Using built-in classifiers
from mts1b_quantkit.regime import vix_regime, trend_regime, classify_regime
# VIX-based
vix_regime(asof=date(2026, 5, 23)) # "low" | "normal" | "high" | "crisis"
# Trend-based
trend_regime("SPY", asof=date(2026, 5, 23)) # "bull" | "chop" | "bear"
# Composite (built from VIX + trend + breadth)
classify_regime(asof=date(2026, 5, 23))
# {"vix": "normal", "trend": "bull", "breadth": "narrow", "composite": "bull"}
Building your own regime classifier
from dataclasses import dataclass
from datetime import date
from typing import Literal
import numpy as np
from mts1b_quantkit.factors import register, ewma_std
from mts1b_foundation.market_data import UniversePanel
@register("f_vol_regime")
def f_vol_regime(panel: UniversePanel, /, lookback: int = 21) -> np.ndarray:
"""Returns +1 (low vol), 0 (normal), -1 (high vol)."""
close = panel.close
log_ret = np.log(close[1:] / close[:-1])
rv = ewma_std(log_ret, alpha=0.94, window=lookback) * np.sqrt(252)
# Cross-sectional median = market vol proxy
market_vol = np.nanmedian(rv, axis=1)
# Z-score over a long window
z = (market_vol - np.nanmean(market_vol[-252:]))
z /= np.nanstd(market_vol[-252:])
out = np.zeros_like(market_vol)
out[z < -0.5] = 1.0 # low vol regime
out[z > 1.5] = -1.0 # high vol regime
return out.reshape(-1, 1) # (T, 1) for compatibility
Gating a factor by regime
@register("f_momentum_regime_gated")
def f_momentum_regime_gated(panel, /, h_long: int = 252, h_skip: int = 21) -> np.ndarray:
"""Momentum, but only trade in bull / low-vol regimes."""
momentum_raw = zscore_cross_sectional(panel.close[-h_skip-1] / panel.close[-h_long-h_skip-1] - 1)
regime = classify_regime(asof=panel.dates[-1])
if regime["composite"] in ("crash", "high_vol"):
return np.zeros_like(momentum_raw) # all flat
if regime["composite"] == "chop":
return momentum_raw * 0.5 # halve exposure
return momentum_raw # full exposure
Per-strategy regime preferences
Different strategies thrive in different regimes:
| Strategy | Bull | Chop | Crash |
|---|---|---|---|
| Momentum | ✅ | ⚠️ | ❌ |
| Mean reversion | ⚠️ | ✅ | ⚠️ |
| Trend following | ✅ | ❌ | ✅ |
| Carry | ✅ | ✅ | ❌ |
| Volatility selling | ✅ | ✅ | ❌❌ |
Use the regime classifier to enable/disable strategies dynamically.
Multi-regime composite strategy
async def regime_aware_compose(asof: datetime) -> dict[str, float]:
"""Choose which sleeves to activate based on current regime."""
regime = classify_regime(asof=asof.date())
sleeves = {}
if regime["composite"] in ("bull", "low_vol"):
sleeves["momentum"] = 0.4
sleeves["carry"] = 0.3
sleeves["vol_selling"] = 0.3
elif regime["composite"] == "chop":
sleeves["mean_reversion"] = 0.5
sleeves["pairs"] = 0.3
sleeves["vol_selling"] = 0.2
elif regime["composite"] == "crash":
sleeves["trend_following"] = 0.5
sleeves["gold_long"] = 0.3
sleeves["cash"] = 0.2
else:
sleeves["cash"] = 1.0
return sleeves
Avoiding regime-chasing
Switching regimes too quickly costs turnover. Use a sticky filter:
def sticky_regime(history: list[str], lookback: int = 5) -> str:
"""Only switch regimes when N consecutive new-regime readings."""
if len(history) < lookback:
return history[-1]
recent = history[-lookback:]
if len(set(recent)) == 1:
return recent[0] # all agree
return history[-lookback-1] # stick with previous
Backtesting a regime-gated strategy
from mts1b_GPUbacktester import run_single
# Compare gated vs ungated
result_raw = run_single(factor=get("f_momentum_12_1"), params={}, ...)
result_gated = run_single(factor=get("f_momentum_regime_gated"), params={}, ...)
print(f"Raw: Sharpe {result_raw.sharpe:.2f} MaxDD {result_raw.max_drawdown:.2%}")
print(f"Gated: Sharpe {result_gated.sharpe:.2f} MaxDD {result_gated.max_drawdown:.2%}")
Typically gating reduces Sharpe slightly (you miss some good periods) but cuts drawdown significantly.
Limitations
Regimes are observed in retrospect
A "crash" is only obvious AFTER it happens. Real-time regime detection lags by 5-30 days. Backtests that use perfect-information regime detection are biased.
mts1b_quantkit.regime enforces a lookback window — it only uses data available asof the decision time.
Don't fit regime thresholds to data
It's tempting to "find" the regime thresholds that maximize backtest Sharpe. Don't. Use externally-motivated thresholds (VIX > 30 is a common crisis indicator; SPY < 200-DMA is a classic trend signal).
If you must tune, use walk-forward CV to verify the thresholds generalize.
See also
mts1b_quantkit.regime— built-in classifiers- Cookbook — Mean reversion + vol filter
- Tutorial — Custom strategy — full lifecycle including regime gates