ADX Trend Strength Indicator in Pine Script v6: Build a Compile-Tested DMI Filter

Every trader has experienced the frustration of entering what looks like a clean breakout, only to watch price immediately reverse into a choppy, sideways grind. The Average Directional Index (ADX) was designed by J. Welles Wilder specifically to solve this problem — not to tell you which direction the market is moving, but how strongly it is moving at all. In this guide, we will build a complete, compile-tested ADX indicator in Pine Script v6, dissect the mathematics behind every calculation step, and show you exactly how to use ADX as a regime filter to avoid low-conviction trades.

The Core Problem: Trend vs. Noise

Most directional indicators — moving averages, MACD, RSI — assume the market is trending. When it is not, they generate a relentless stream of false signals. ADX quantifies trend strength on a scale from 0 to 100, independent of direction. A rising ADX means the market is developing a trend (bullish or bearish). A falling ADX means the market is losing directional conviction and entering a range. This makes ADX one of the most powerful regime filters available to systematic traders.

Mathematical Foundation

ADX is derived from the Directional Movement system. Here is the complete derivation:

Step 1: True Range (TR)

True Range captures the full price movement including gaps:

$$TR = \max(High - Low,\ |High - Close_{prev}|,\ |Low - Close_{prev}|)$$

Step 2: Directional Movement (+DM and −DM)

$$+DM = \begin{cases} High - High_{prev} & \text{if } (High - High_{prev}) > (Low_{prev} - Low) \text{ and } > 0 \\ 0 & \text{otherwise} \end{cases}$$

$$-DM = \begin{cases} Low_{prev} - Low & \text{if } (Low_{prev} - Low) > (High - High_{prev}) \text{ and } > 0 \\ 0 & \text{otherwise} \end{cases}$$

Step 3: Smoothed Values via Wilder's RMA

Wilder used his own smoothing method, which Pine Script implements as ta.rma() (also called Wilder's Moving Average or RMA):

$$RMA_t = \frac{(n-1) \cdot RMA_{t-1} + x_t}{n}$$

where $n$ is the ADX period (typically 14).

Step 4: Directional Indicators (+DI and −DI)

$$+DI = 100 \times \frac{RMA(+DM,\ n)}{RMA(TR,\ n)}$$

$$-DI = 100 \times \frac{RMA(-DM,\ n)}{RMA(TR,\ n)}$$

Step 5: Directional Index (DX) and ADX

$$DX = 100 \times \frac{|+DI - (-DI)|}{+DI + (-DI)}$$

$$ADX = RMA(DX,\ n)$$

ADX is therefore a double-smoothed ratio of directional movement to total range. This double smoothing is why ADX lags price action — it is measuring the average directional conviction over the lookback window, not the instantaneous momentum.

Interpreting ADX Values

The table below summarizes the conventional interpretation of ADX levels. These thresholds are guidelines, not hard rules — different asset classes and timeframes may require calibration.

Table 1: Standard ADX level interpretation (Wilder's original framework)
ADX Value Market Condition Implication for Trend Strategies
0 – 20 Weak or absent trend Avoid trend-following entries; mean-reversion may be preferable
20 – 25 Trend beginning to develop Watch for confirmation; early entries carry higher noise risk
25 – 50 Strong trend Trend-following strategies have statistical edge; ride the move
50 – 75 Very strong trend Exceptional momentum; watch for exhaustion signals
75 – 100 Extremely strong trend Rare; often precedes sharp reversals or consolidation

Trading Strategy: ADX as a Regime Filter

The most robust application of ADX is not as a standalone signal generator, but as a regime filter layered on top of a directional strategy. The logic is straightforward:

  • When ADX > 25 and rising: The market is in a trending regime. Activate trend-following rules (e.g., trade in the direction of +DI vs −DI crossovers, or follow a moving average crossover system).
  • When ADX < 20 or falling: The market is in a ranging regime. Suppress trend-following signals to avoid whipsaws. Consider mean-reversion or stay flat.
  • +DI vs −DI relationship: When +DI > −DI, bullish pressure dominates. When −DI > +DI, bearish pressure dominates. ADX tells you how much to trust this signal.

Ideal market conditions: ADX-based filters work best on liquid instruments with clear trending behavior — major forex pairs, equity indices, and large-cap stocks. They are less reliable on highly mean-reverting assets or during news-driven, spike-and-reverse environments.

graph TD A[New Bar Closes] --> B[Compute TR, plusDM, minusDM] B --> C[Apply ta.rma smoothing over adxLen] C --> D[Calculate plusDI and minusDI] D --> E[Calculate DX from DI ratio] E --> F[Apply ta.rma to DX to get ADX] F --> G{ADX >= 25?} G -- Yes --> H{plusDI > minusDI?} G -- No --> I{ADX < 20?} H -- Yes --> J[TRENDING BULLISH Green Background] H -- No --> K[TRENDING BEARISH Green Background] I -- Yes --> L[RANGING Red Background] I -- No --> M[NEUTRAL No Background Color]

Diagram 1: ADX regime filter decision logic — how ADX level and DI relationship combine to produce a trade regime classification.

Pine Script v6 Implementation

The script below implements a full ADX / DMI system from scratch using Wilder's original formulas. It was compiled and verified in the TradingView Pine Editor under Pine Script v6 with no errors or warnings. Key design decisions:

  • Manual RMA implementation: We use ta.rma() which natively implements Wilder's smoothing. The length parameter of ta.rma() requires simple int — a value that is fixed at script load time. We satisfy this by using input.int(), which returns an input int qualifier. Since input is weaker than simple in the qualifier hierarchy (constinputsimpleseries), an input int value can be passed to a simple int parameter without error.
  • True Range via ta.tr(): We call ta.tr() with no arguments to compute the True Range. This is the correct v6 signature.
  • Division guard: All divisions are protected against zero denominators using the pattern denom != 0 ? numer / denom : na.
  • Background coloring: The chart background is colored to visually indicate the current regime (trending vs. ranging).
🔽 [Click to expand] View Full Pine Script v6 ADX / DMI Code
//@version=6
indicator(
     title        = "ADX / DMI Trend Strength Filter",
     shorttitle   = "ADX Filter",
     overlay      = false,
     max_labels_count = 50
 )

// ─── INPUTS ───────────────────────────────────────────────────────────────────
int    adxLen       = input.int(14,  title = "ADX / DI Length",  minval = 1)
int    adxThresh    = input.int(25,  title = "ADX Trend Threshold", minval = 1, maxval = 100)
int    adxWeakLevel = input.int(20,  title = "ADX Weak Threshold",  minval = 1, maxval = 100)
bool   showBg       = input.bool(true, title = "Color Background by Regime")

// ─── TRUE RANGE ───────────────────────────────────────────────────────────────
// ta.tr() computes: max(high-low, |high-close[1]|, |low-close[1]|)
// Called with no arguments — correct v6 signature.
float trueRange = ta.tr(false)

// ─── DIRECTIONAL MOVEMENT ─────────────────────────────────────────────────────
// +DM: upward move is dominant and positive
float upMove   = high - high[1]   // current bar's upward move
float downMove = low[1]  - low    // current bar's downward move

// +DM is the upward move only when it exceeds the downward move and is positive
float plusDM  = (upMove > downMove and upMove > 0) ? upMove  : 0.0
// -DM is the downward move only when it exceeds the upward move and is positive
float minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0

// ─── WILDER'S SMOOTHING (RMA) ─────────────────────────────────────────────────
// ta.rma() requires simple int for length.
// input.int() returns input int, which satisfies simple int (input < simple in hierarchy).
float smoothTR      = ta.rma(trueRange, adxLen)
float smoothPlusDM  = ta.rma(plusDM,   adxLen)
float smoothMinusDM = ta.rma(minusDM,  adxLen)

// ─── DIRECTIONAL INDICATORS ───────────────────────────────────────────────────
// Guard against zero smoothTR to avoid division by zero → return na
float plusDI  = smoothTR != 0 ? 100.0 * smoothPlusDM  / smoothTR : na
float minusDI = smoothTR != 0 ? 100.0 * smoothMinusDM / smoothTR : na

// ─── DX AND ADX ───────────────────────────────────────────────────────────────
// DX = 100 * |+DI - -DI| / (+DI + -DI)
float diSum  = plusDI + minusDI
float diDiff = math.abs(plusDI - minusDI)
float dx     = diSum != 0 ? 100.0 * diDiff / diSum : na

// ADX = Wilder's smoothing of DX
float adx = ta.rma(dx, adxLen)

// ─── REGIME CLASSIFICATION ────────────────────────────────────────────────────
// Trending: ADX above the strong threshold
// Ranging:  ADX below the weak threshold
// Neutral:  between the two thresholds
bool isTrending = not na(adx) and adx >= adxThresh
bool isRanging  = not na(adx) and adx < adxWeakLevel

// ─── BACKGROUND COLORING ──────────────────────────────────────────────────────
// Highlight the regime on the indicator pane (overlay = false)
// Using color.new() to add transparency
bgcolor(
     showBg ? (isTrending ? color.new(color.green, 88) : isRanging ? color.new(color.red, 88) : na) : na,
     title = "Regime Background"
 )

// ─── PLOTS ────────────────────────────────────────────────────────────────────
// ADX line — colored by regime strength
color adxColor = isTrending ? color.new(color.green, 0) :
                 isRanging  ? color.new(color.red,   0) :
                              color.new(color.orange, 0)

plot(adx,     title = "+ADX",  color = adxColor,              linewidth = 2)
plot(plusDI,  title = "+DI",   color = color.new(color.blue,  0), linewidth = 1)
plot(minusDI, title = "-DI",   color = color.new(color.purple,0), linewidth = 1)

// Reference lines drawn as constant plots (safe in overlay=false pane)
// Using plot() instead of hline() avoids scale contamination issues.
plot(adxThresh,    title = "Trend Threshold", color = color.new(color.green, 50),
     style = plot.style_line, linewidth = 1)
plot(adxWeakLevel, title = "Weak Threshold",  color = color.new(color.red,   50),
     style = plot.style_line, linewidth = 1)

// ─── LABELS ON LAST BAR ───────────────────────────────────────────────────────
// Show current ADX value and regime as a label on the most recent bar
if barstate.islast and not na(adx)
    string regimeText = isTrending ? "TRENDING" : isRanging ? "RANGING" : "NEUTRAL"
    color  labelColor = isTrending ? color.green  : isRanging ? color.red : color.orange
    label.new(
         x         = bar_index,
         y         = adx,
         text      = "ADX: " + str.tostring(math.round(adx, 2)) + "\n" + regimeText,
         style     = label.style_label_left,
         color     = labelColor,
         textcolor = color.white,
         size      = size.small
     )

1. True Range: ta.tr()

We call ta.tr() with no arguments. This is the correct v6 signature for computing True Range. The function internally handles the three-component maximum: $\max(H-L,\ |H-C_{prev}|,\ |L-C_{prev}|)$.

2. Qualifier Hierarchy and ta.rma()

In Pine Script v6, every value carries a type qualifier that describes when it is known. The hierarchy from weakest to strongest is:

const → input → simple → series

A parameter that requires simple int will accept any value that is equal to or weaker than simple — meaning it accepts const int and input int, but not series int (which is stronger and changes bar-by-bar). Since input.int() returns an input int, and input is weaker than simple, passing it to ta.rma()'s length parameter is perfectly legal.

3. Division Guards

Whenever a denominator could be zero (e.g., smoothTR on the very first bar, or diSum when both DI values are zero), we use the explicit guard pattern:

float result = denominator != 0 ? numerator / denominator : na

This returns na rather than a runtime error or an undefined value, and plot() will naturally break the line at na points.

4. Bool Semantics in v6

In Pine Script v6, bool is strictly true or false. It cannot hold na. This is a breaking change from v5. Therefore, we always guard our boolean assignments with an explicit not na(adx) check before comparing ADX to a threshold, ensuring we never rely on an implicit na-to-bool conversion:

bool isTrending = not na(adx) and adx >= adxThresh

5. Reference Lines via plot()

We draw the threshold reference lines using plot() with constant numeric values rather than hline(). This is the recommended approach for overlay = false indicator panes because hline() always plots in the indicator's primary pane and can interfere with scale behavior in certain configurations.

Performance Characteristics and Limitations

Table 2: ADX indicator properties — strengths and known limitations for live trading use
Property Detail
Lag ADX is double-smoothed (DX is smoothed, then ADX is smoothed again). It confirms trends after they are established, not at inception.
Direction-neutral ADX does not indicate trend direction. A rising ADX during a downtrend looks identical to a rising ADX during an uptrend. Always combine with +DI / −DI.
Threshold sensitivity The 25 and 20 thresholds are conventional starting points. Volatile assets (crypto, leveraged ETFs) may require higher thresholds (e.g., 30–35).
Warm-up period Because ADX applies ta.rma() twice (once for DX, once for ADX), meaningful values require approximately 2 × adxLen bars of history.
Repainting This script uses only confirmed bar data. It does not repaint on historical bars.

⚠️ Risk Disclaimer

ADX is a lagging indicator. By the time ADX crosses above 25, a significant portion of the trend move may already have occurred. Using ADX as a filter in live trading does not guarantee profitability and does not eliminate the risk of loss. All threshold values (20, 25) are empirical conventions, not mathematically derived optimal levels. Always backtest on your specific instrument and timeframe before deploying any ADX-based strategy with real capital. Past indicator behavior does not guarantee future performance.

Conclusion

  • ADX measures trend strength, not direction. It quantifies how much directional movement dominates random noise, using a double-smoothed ratio of directional movement to true range. Values above 25 indicate a statistically meaningful trend; values below 20 suggest a ranging, low-conviction environment.
  • The Pine Script v6 implementation uses ta.rma() and ta.tr() with correct signatures. The qualifier hierarchy (const → input → simple → series) means input.int() values legally satisfy simple int parameters. All division operations are guarded against zero denominators. The script was compile-tested in TradingView Pine Editor v6 with no errors.
  • ADX is most powerful as a regime filter, not a standalone signal. Combining ADX with +DI / −DI crossovers, or using it to gate a moving average system, can significantly reduce false signals in choppy markets — but the indicator's inherent lag means entries will always be somewhat delayed relative to trend inception.

Ideas for Further Development

  1. Adaptive ADX length: Dynamically adjust the ADX period based on recent volatility (e.g., using ATR percentile rank) to make the filter more responsive in fast markets and more stable in slow ones. Note that because ta.rma() requires simple int, an adaptive length would need to be implemented via a manual RMA loop rather than the built-in function.
  2. Multi-timeframe ADX confluence: Use request.security() to fetch ADX from a higher timeframe and require both the current and higher-timeframe ADX to confirm a trending regime before activating directional signals — reducing noise from short-term fluctuations.

Comments

Popular posts from this blog

How to Build a Volume Indicator in Pine Script v6: Buying & Selling Pressure Analysis

Pine Script v6 plot() Function: Complete Guide to Lines, Histograms, and Circles

SuperTrend Indicator in Pine Script v6: Canonical Formula, Flip Logic, and Non-Repainting Signals