Market Regime Detection

Systematically identify whether the market is trending or ranging using ADX, Bollinger Band® width, and ATR percentile — and deploy only the appropriate strategy for each regime.

Markets spend time in two fundamentally different states: trending and ranging. In a trending state, price makes consistent directional progress — higher highs and higher lows in an uptrend, lower lows and lower highs in a downtrend. In a ranging state, price oscillates between support and resistance without sustained directional conviction.

The critical insight is that these two states require completely different strategy approaches. A breakout or momentum strategy that performs excellently during a trend will get chopped apart during a range — it buys breakouts that immediately reverse, and sells breakdowns that immediately recover. Conversely, a mean-reversion strategy that thrives in a range will produce a string of losses during a trend — it fades moves that keep going.

Market regime detection gives you a systematic, objective way to determine which state the market is in before applying any strategy. Rather than using discretion, the regime is scored from multiple independent measures — ADX, Bollinger Band® width percentile, and ATR percentile — and a combined score drives the assessment.

Why the regime matters before any other decision

Before asking "what is the signal?" the more important question is "what kind of market is this?" A strategy's statistical edge is conditional on market regime. The same EMA crossover system has a positive expectancy in trending conditions and a negative expectancy in ranging conditions. The setup doesn't change — the regime does.

This is why regime detection should be treated as a prerequisite filter, not an optional addition. Every entry signal should pass a regime check: is the current condition appropriate for the type of strategy generating this signal?

Method 1: ADX (Average Directional Index)

ADX directly measures trend strength on a 0–100 scale. It does not measure direction — only the intensity of directional movement, regardless of whether that movement is up or down.

The 25 threshold is the standard starting point, but it can be adjusted for the instrument and timeframe. Faster instruments on shorter timeframes may need a lower threshold; slower instruments on daily charts may warrant a higher one.

Method 2: Bollinger Band® Width Percentile

Bollinger Band® width — `(upper − lower) / basis` — measures how "wide" the bands are relative to the moving average. Wide bands = high volatility = price is moving. Narrow bands = compression = price is coiling.

The absolute width is not meaningful across different instruments and timeframes. The percentile rank of the current width relative to the last N bars is what matters: is the current width in the top half or bottom half of its recent historical distribution?

`ta.percentrank(bbWidth, 100)` returns 0–100: where the current band width sits within the last 100 values. A reading above 50 means bands are wider than their median over the last 100 bars — volatility is above average, which is consistent with trending conditions. Below 50 means compression.

Method 3: ATR Percentile

ATR measures the average bar range. Like band width, the absolute ATR value is instrument-specific. Comparing it to its own recent history via percentile rank gives a normalised reading.

`ta.percentrank(ta.atr(14), 100)` returns where the current ATR sits within the last 100 ATR values. High ATR percentile = bars are larger than usual = directional activity. Low ATR percentile = bars are small and compressed = ranging or consolidation.

The combined score

No single measure is reliable in isolation. ADX can lag at trend starts; band width can be high during volatile ranges; ATR percentile reflects all volatility, not just directional volatility.

Combining all three into a score (0–3) reduces false readings:

Practical application

The regime score is most valuable as a strategy gate: before your strategy generates an entry, check the regime. If it doesn't match, skip the trade.

A simple implementation:

`if trendingRegime and bullishBreakoutSignal` → take the trade `if rangingRegime and meanReversionSignal` → take the trade `if regime doesn't match signal type` → pass

Over time, this filter should improve the strategy's profit factor even as it reduces total trade count — you are selecting only the trades where the strategy's edge is most likely to be present.

//@version=6
indicator("Market Regime Detection", overlay=false)

adxLen    = input.int(14,   "ADX Length")
adxThresh = input.float(25, "ADX Trend Threshold", minval=10, maxval=50)
lookback  = input.int(100,  "Percentile Lookback",  minval=20)

// ── Method 1: ADX ────────────────────────────────────────────────────
[diPlus, diMinus, adxVal] = ta.dmi(adxLen, adxLen)
adxTrending = adxVal > adxThresh

// ── Method 2: Bollinger Band® Width Percentile ─────────────────────────
[bbBasis, bbUpper, bbLower] = ta.bb(close, 20, 2.0)
bbWidth     = (bbUpper - bbLower) / bbBasis
bbwPct      = ta.percentrank(bbWidth, lookback)
bbwTrending = bbwPct > 50

// ── Method 3: ATR Percentile ──────────────────────────────────────────
atrPct      = ta.percentrank(ta.atr(14), lookback)
atrTrending = atrPct > 50

// ── Combined regime score (0 = ranging, 3 = strong trend) ─────────────
score    = (adxTrending ? 1 : 0) + (bbwTrending ? 1 : 0) + (atrTrending ? 1 : 0)
trending = score >= 2
ranging  = score <= 1

// ── Plots ─────────────────────────────────────────────────────────────
hline(adxThresh, "ADX Threshold",   color=color.new(color.blue, 40), linestyle=hline.style_dashed)
hline(50,        "50th Percentile", color=color.gray,               linestyle=hline.style_dotted)

plot(adxVal,       "ADX",           color=color.blue,   linewidth=2)
plot(bbwPct,       "BB Width %ile", color=color.purple, linewidth=1)
plot(atrPct,       "ATR %ile",      color=color.orange, linewidth=1)
plot(score * 20,   "Regime Score",  color=color.white,  linewidth=3, style=plot.style_histogram)

bgcolor(trending ? color.new(color.green, 90) : ranging ? color.new(color.red, 90) : na)