Characteristics: - Clear directional movement
- Higher highs/lows (uptrend) or lower highs/lows (downtrend)
- Moving averages slope consistently
- Momentum indicators stay extended
Best strategies: - Trend following
- Breakout trading
- Momentum strategies
- Moving average systems
Characteristics: - Price oscillates between support and resistance
- No clear directional bias
- Moving averages flat and intertwined
- Mean reversion works
Best strategies: - Range trading (buy support, sell resistance)
- Mean reversion
- Options strategies (selling premium)
- Grid trading
Characteristics: - Large, unpredictable swings
- False breakouts common
- High ATR relative to price
- Stops get hit frequently
Best strategies: - Wider stops or no positions
- Reduced position sizes
- Volatility trading
- Wait for regime change
ADX measures trend strength, not direction.
Readings: - ADX < 20: No trend (ranging)
- ADX 20-40: Developing trend
- ADX > 40: Strong trend
- ADX > 60: Extreme trend (watch for exhaustion)
Implementation: ```python
import ta
adx = ta.trend.adx(df['high'], df['low'], df['close'], window=14)
def get_adx_regime(adx_value):
if adx_value < 20:
return 'ranging'
elif adx_value < 40:
return 'weak_trend'
else:
return 'strong_trend'
### Bollinger Band Width
Measures volatility relative to recent history.
Readings: - Low bandwidth: Consolidation, expect expansion
- High bandwidth: Elevated volatility
- Bandwidth squeeze: Breakout imminent
Calculation: ```python
## Bollinger Band Width
bb_high = ta.volatility.bollinger_hband(df['close'])
bb_low = ta.volatility.bollinger_lband(df['close'])
bb_width = (bb_high - bb_low) / df['close']
## Width percentile
width_percentile = bb_width.rolling(100).rank(pct=True)
Compare current volatility to historical range.
## ATR
atr = ta.volatility.average_true_range(df['high'], df['low'], df['close'])
## ATR as percentage of price
atr_pct = atr / df['close']
## Percentile over last 252 periods
atr_percentile = atr_pct.rolling(252).rank(pct=True)
## Regime
def get_volatility_regime(percentile):
if percentile < 0.2:
return 'low_volatility'
elif percentile < 0.8:
return 'normal_volatility'
else:
return 'high_volatility'
The relationship between fast and slow MAs indicates regime.
sma_20 = df['close'].rolling(20).mean()
sma_50 = df['close'].rolling(50).mean()
sma_200 = df['close'].rolling(200).mean()
## MA slope (20-day)
ma_slope = (sma_20 - sma_20.shift(10)) / sma_20.shift(10)
## MA separation
ma_separation = abs(sma_20 - sma_50) / df['close']
## Regime logic
def get_ma_regime(slope, separation):
if abs(slope) < 0.01 and separation < 0.02:
return 'ranging'
elif slope > 0.02:
return 'uptrend'
elif slope < -0.02:
return 'downtrend'
else:
return 'transitioning'
Single indicators give false signals. Combine multiple for reliability.
def calculate_regime_score(df):
"""
Returns regime classification with confidence
"""
scores = {
'trending': 0,
'ranging': 0,
'volatile': 0
}
# ADX component
adx = df['adx'].iloc[-1]
if adx > 25:
scores['trending'] += 2
elif adx < 20:
scores['ranging'] += 2
# Bollinger width component
bb_percentile = df['bb_width_pct'].iloc[-1]
if bb_percentile < 0.25:
scores['ranging'] += 1
elif bb_percentile > 0.75:
scores['volatile'] += 2
# MA slope component
slope = df['ma_slope'].iloc[-1]
if abs(slope) > 0.02:
scores['trending'] += 2
elif abs(slope) < 0.005:
scores['ranging'] += 1
# ATR component
atr_percentile = df['atr_percentile'].iloc[-1]
if atr_percentile > 0.8:
scores['volatile'] += 2
elif atr_percentile < 0.3:
scores['ranging'] += 1
# Determine regime
regime = max(scores, key=scores.get)
confidence = scores[regime] / sum(scores.values()) if sum(scores.values()) > 0 else 0
return {
'regime': regime,
'confidence': confidence,
'scores': scores
}
position sizing: Full size (100%)
- Stop placement: Below swing lows (uptrend) or above swing highs (downtrend)
- Entry method: Pullbacks to moving averages
- Exit method: Trailing stops, MA crosses
Example strategy: - Wait for price above 50 SMA
- Enter on pullback to 20 SMA
- Stop below recent swing low
- Trail stop with 20 SMA
position sizing: Reduced (50-75%)
- Stop placement: Beyond range boundaries
- Entry method: At support/resistance
- Exit method: At opposite boundary
Example strategy: - Identify range high and low
- Buy at range low with confirmation
- Sell at range high
- Stop loss if range breaks
position sizing: Minimum (25-50%) or flat
- Stop placement: Very wide or no hard stops
- Entry method: Only on extreme readings
- Exit method: Quick profits, don't hold
Example strategy: - Wait for volatility to contract
The most profitable (and dangerous) periods are transitions between regimes.
- ADX declining from high levels
- Failed breakouts
- Lower highs AND higher lows forming
- Decreasing momentum
- Bollinger squeeze (low width)
- Increasing volume
- Break of range with follow-through
- ADX turning up from low levels
- Decreasing ATR
- Smaller daily ranges
- Stabilizing price action
- Volume normalization
features = [
'adx',
'adx_change', # ADX momentum
'bb_width',
'bb_width_percentile',
'atr_pct',
'atr_percentile',
'ma_slope_20',
'ma_slope_50',
'ma_separation',
'volume_ratio', # Volume vs average
'range_pct', # Daily range as % of price
'close_vs_ma50', # Price relative to MA
]
from sklearn.ensemble import RandomForestClassifier
def train_regime_classifier(df, features, labels):
"""
Train a classifier to detect market regimes
"""
X = df[features].dropna()
y = labels.loc[X.index]
model = RandomForestClassifier(
n_estimators=100,
max_depth=5,
random_state=42
)
model.fit(X, y)
return model
## Generate labels (can be manual or rule-based initially)
## Then refine with model predictions
More sophisticated approach that captures regime persistence:
from hmmlearn import hmm
def fit_regime_hmm(returns, n_regimes=3):
"""
Fit Hidden Markov Model to detect regimes
"""
model = hmm.
GaussianHMM(
n_components=n_regimes,
covariance_type="full",
n_iter=100
)
model.fit(returns.values.reshape(-1, 1))
regimes = model.predict(returns.values.reshape(-1, 1))
return model, regimes
Every day before trading:
- Calculate current ADX
- Check Bollinger Band width percentile
- Note ATR relative to history
- Assess MA alignment and slope
- Combine into regime classification
- Adjust strategy accordingly
Track these metrics:
| Metric |
Current |
7-Day Avg |
Signal |
| ADX |
28 |
25 |
Trending |
| BB Width %ile |
35% |
45% |
Normal |
| ATR %ile |
55% |
60% |
Normal |
| MA Slope |
+1.5% |
+1.2% |
Uptrend |
| Regime |
|
|
Trending |
Set alerts for regime changes:
def check_regime_change(current_regime, previous_regime):
if current_regime != previous_regime:
send_alert(f"Regime change: {previous_regime} → {current_regime}")
log_regime_change(current_regime)
A trend-following system will lose money in ranges. Accept that some regimes aren't suitable for your strategy.
Regimes can last days to months. Don't switch strategies on every fluctuation. Require confirmation.
The shift between regimes is often where money is made or lost. Stay alert during transitions.
Simple rules that capture the essence work better than complex systems that curve-fit to history.
How long do market regimes typically last?
Varies widely. Trending regimes can last weeks to months. Ranges can persist for extended periods. Volatile regimes are usually shorter but more intense.
Should I trade all regimes?
No. Most strategies work in specific regimes. It's better to sit out unfavorable regimes than force trades.
Can regimes change intraday?
Yes, especially on lower timeframes. Daily regime assessment is usually sufficient for swing trading. Day traders may need more frequent checks.
What's the best indicator for regime detection?
ADX combined with Bollinger Band width covers most situations. Add ATR percentile for a more complete picture.