A momentum scanner in a crash is a money furnace. A regime detector analyzes 6 market indicators daily and shifts your strategy weights automatically. Build one from scratch.
In March 2020, a momentum scanner would have been screaming "BUY" on hundreds of stocks that had just bounced 5-10% off their lows. RSI was climbing, volume was surging, MACD was crossing up. Every technical signal said "go." The market then proceeded to drop another 20% in the following week.
The problem: the scanner had no concept of regime. It did not know that VIX was at 82, credit spreads were blowing out, and the Fed had just emergency-cut rates to zero. The same signals that work beautifully in a bull market become death traps in a crash.
The same setup (RSI oversold + volume spike + MACD crossover) has a 62% win rate in Risk-On markets and a 28% win rate in Risk-Off markets. Without regime detection, you are averaging those two outcomes and calling it a "strategy." That is like averaging the temperature of Sahara and Antarctica and dressing for 15C.
Markets do not exist in a binary "bull" or "bear" state. There are five distinct regimes, each requiring a different scanner configuration:
SPY above rising EMAs, VIX below 18, DXY weak, credit tight. Playbook: Heavy momentum (45%), breakout (30%), contraction (20%), minimal mean-reversion (5%). This is where trend-following prints money.
SPY reclaiming EMAs, VIX falling from elevated levels, breadth improving. Playbook: Balanced momentum (30%), breakout (25%), contraction (25%), mean-reversion (20%). The market is healing — look for leaders emerging from the wreckage.
Mixed signals. SPY choppy, VIX 18-25, no clear direction. Playbook: Equal weight across all strategies (25% each). Focus on stock-picking over market direction.
SPY breaking below EMA50, VIX rising above 25, credit widening. Playbook: Mean-reversion (40%), contraction (35%), minimal momentum (15%), defensive breakout on hedges (10%). Shift to defensive names and short setups.
SPY below EMA200, VIX above 30, credit blowout, DXY surging. Playbook: Mean-reversion only on quality names (45%), contraction (40%), short candidates (10%), no momentum (5%). Capital preservation is priority one.
| Regime | Score Range | VIX Typical | Key Behavior | Scanner Action |
|---|---|---|---|---|
| Risk-On | > 0.75 | < 18 | Strong uptrend, low fear | Aggressive momentum and breakout |
| Recovery | 0.60 – 0.75 | 18 – 22 | Improving breadth | Balanced, look for leaders |
| Neutral | 0.40 – 0.60 | 18 – 25 | Choppy, no direction | Equal weight, stock-picking |
| Early Risk-Off | 0.25 – 0.40 | 25 – 35 | Deteriorating, caution | Defensive, hedges, oversold quality |
| Risk-Off | < 0.25 | > 30 | Crash / panic | Preservation, short setups, GLD/TLT |
The regime detector uses 6 indicators, each producing a score from 0.0 (bearish) to 1.0 (bullish). The indicators are weighted and combined into a single regime score.
| # | Indicator | Weight | Signal | Computation |
|---|---|---|---|---|
| 1 | S&P 500 Trend | 30% | EMA(20) vs EMA(50) | 1.0 if EMA20 > EMA50 by >2%, 0.5 if flat, 0.0 if EMA20 < EMA50 by >2% |
| 2 | VIX Level (inverted) | 25% | Fear gauge | 1.0 if VIX < 15, 0.5 if 20-25, 0.0 if > 35 |
| 3 | Dollar DXY (inverted) | 15% | Risk appetite | 1.0 if DXY falling >1%, 0.5 if flat, 0.0 if surging >1% |
| 4 | Credit HYG/LQD | 15% | Institutional risk | 1.0 if ratio rising (risk-on), 0.0 if falling (flight to quality) |
| 5 | Treasury TLT (inverted) | 10% | Bond vs equity | 1.0 if TLT falling (money into stocks), 0.0 if surging (flight to safety) |
| 6 | Bitcoin Momentum | 5% | Liquidity proxy | 1.0 if BTC EMA20 > EMA50, 0.0 otherwise |
class RegimeDetector: """Detect market regime from 6 macro indicators.""" def __init__(self): self.weights = { 'spy_trend': 0.30, 'vix_level': 0.25, 'dxy_trend': 0.15, 'credit_spread': 0.15, 'tlt_trend': 0.10, 'btc_momentum': 0.05, } def _score_spy(self, spy_data): """Score SPY trend: EMA20 vs EMA50.""" ema20 = spy_data['ema_20'] ema50 = spy_data['ema_50'] spread = (ema20 - ema50) / ema50 if spread > 0.02: return 1.0 elif spread > 0.005: return 0.75 elif spread > -0.005: return 0.5 elif spread > -0.02: return 0.25 else: return 0.0 def _score_vix(self, vix): """Score VIX (inverted: low VIX = bullish).""" if vix < 15: return 1.0 elif vix < 20: return 0.75 elif vix < 25: return 0.5 elif vix < 35: return 0.25 else: return 0.0 def detect(self, market_data): """Run all 6 indicators. Return regime + score.""" scores = { 'spy_trend': self._score_spy(market_data['SPY']), 'vix_level': self._score_vix(market_data['VIX']), 'dxy_trend': self._score_dxy(market_data['DXY']), 'credit_spread': self._score_credit(market_data), 'tlt_trend': self._score_tlt(market_data['TLT']), 'btc_momentum': self._score_btc(market_data['BTC']), } # Weighted composite regime_score = sum( scores[k] * self.weights[k] for k in scores ) # Map to regime name if regime_score > 0.75: regime = 'risk_on' elif regime_score > 0.60: regime = 'recovery' elif regime_score > 0.40: regime = 'neutral' elif regime_score > 0.25: regime = 'early_risk_off' else: regime = 'risk_off' return { 'regime': regime, 'score': round(regime_score, 3), 'indicators': scores, }
SPY trend (30%) is the king — if the market is going up, most stocks go up. VIX (25%) measures fear intensity. DXY (15%) captures global risk appetite — a strong dollar means money is fleeing to safety. HYG/LQD (15%) is the institutional tell — when institutions dump high-yield bonds for investment-grade, they are positioning for trouble. TLT (10%) is the bond refuge indicator. BTC (5%) is a liquidity barometer — crypto dies first when liquidity dries up.
Once you know the regime, you adjust the weight given to each scanner strategy. This is where the real magic happens — the scanner literally reconfigures itself every morning based on what the market is doing.
| Strategy | Risk-On | Recovery | Neutral | Early Risk-Off | Risk-Off |
|---|---|---|---|---|---|
| Momentum | 45% | 30% | 25% | 15% | 5% |
| Breakout | 30% | 25% | 25% | 10% | 10% |
| Contraction | 20% | 25% | 25% | 35% | 40% |
| Mean Reversion | 5% | 20% | 25% | 40% | 45% |
STRATEGY_WEIGHTS = {
'risk_on': {'momentum': 45, 'breakout': 30, 'contraction': 20, 'mean_reversion': 5},
'recovery': {'momentum': 30, 'breakout': 25, 'contraction': 25, 'mean_reversion': 20},
'neutral': {'momentum': 25, 'breakout': 25, 'contraction': 25, 'mean_reversion': 25},
'early_risk_off': {'momentum': 15, 'breakout': 10, 'contraction': 35, 'mean_reversion': 40},
'risk_off': {'momentum': 5, 'breakout': 10, 'contraction': 40, 'mean_reversion': 45},
}
def get_weights(regime):
"""Return strategy weights for detected regime."""
return STRATEGY_WEIGHTS[regime]
The regime detector runs at the start of each daily scan. The full loop:
def daily_scan(universe_df, market_data): """Full daily scan: regime -> filter -> score -> rank.""" # Step 1: Detect regime detector = RegimeDetector() regime_result = detector.detect(market_data) regime = regime_result['regime'] print(f"Regime: {regime} (score: {regime_result['score']})") # Step 2: Get strategy weights weights = get_weights(regime) print(f"Weights: {weights}") # Step 3: Run filters (from Part 2) pipeline = ScannerPipeline() candidates = pipeline.run(universe_df, regime) # Step 4: Score candidates (from Part 3) scorer = ScoringEngine() all_scored = [] for strategy, weight in weights.items(): strat_candidates = candidates[candidates['strategy'] == strategy] scored = scorer.score_candidates(strat_candidates, strategy, weight) all_scored.append(scored) # Step 5: Merge and rank merged = pd.concat(all_scored) merged = merged.sort_values('final_score', ascending=False) merged = merged.drop_duplicates(subset=['ticker'], keep='first') # Step 6: Take top 10 with diversification top10 = enforce_diversification(merged.head(20), max_per_sector=2) print(f"\nTop 10 Setups ({regime}):") for _, row in top10.head(10).iterrows(): print(f" {row['ticker']:6s} | {row['strategy']:15s} | Score: {row['final_score']}") return top10.head(10)
If the regime score hovers near a boundary (e.g., 0.41 one day, 0.39 the next), your scanner will whip between Neutral and Early Risk-Off strategies daily. To prevent this, add hysteresis: require the score to move at least 0.05 past a boundary before switching regime. Alternatively, use a 3-day exponential average of the regime score instead of the raw daily value.