Skip to main content

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

RegimeSignal
BullSPY > 200-DMA, VIX < 20
ChopSPY ~ 200-DMA, 20-DMA std > N-DMA mean
CrashSPY < 200-DMA, VIX > 30
High-volVIX > 35
Low-volVIX < 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:

StrategyBullChopCrash
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