The ADX is the most involved indicator in this series, but it’s still just a chain of the pieces you’ve already seen: true range, Wilder smoothing, and some ratios. The explainer lays out the logic; here’s the whole chain in pandas.
What you’ll need
- Python 3.9+,
pandas/numpy high,low,closeseries.
The full chain
import numpy as np
import pandas as pd
def adx(high, low, close, period: int = 14):
up = high.diff()
down = -low.diff()
# Directional movement: only the dominant, positive move counts.
plus_dm = up.where((up > down) & (up > 0), 0.0)
minus_dm = down.where((down > up) & (down > 0), 0.0)
# True range and its Wilder-smoothed ATR.
prev_close = close.shift(1)
tr = pd.concat([high - low, (high - prev_close).abs(),
(low - prev_close).abs()], axis=1).max(axis=1)
alpha = 1 / period
atr = tr.ewm(alpha=alpha, adjust=False).mean()
plus_di = 100 * plus_dm.ewm(alpha=alpha, adjust=False).mean() / atr
minus_di = 100 * minus_dm.ewm(alpha=alpha, adjust=False).mean() / atr
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di)
adx_line = dx.ewm(alpha=alpha, adjust=False).mean()
return pd.DataFrame({"plus_di": plus_di, "minus_di": minus_di, "adx": adx_line})
Read it top to bottom and it mirrors the explainer exactly: directional movement → smooth and normalize by ATR → DI lines → DX (their normalized separation) → smooth into ADX.
The regime filter, in code
The most valuable use of ADX is as a filter on other signals:
a = adx(df["high"], df["low"], df["close"])
trending = a["adx"] > 25
bull = trending & (a["plus_di"] > a["minus_di"]) # strong up-trend regime
Use bull to gate a trend-following entry, so you only act when a trend actually
exists — then backtest whether the filter helps in AlgoGen.
Gotchas
- The DM rule. Only the larger of the up/down move counts, and only if it’s
positive. The
.where(...)conditions encode exactly that; getting this wrong is the classic ADX bug. - Wilder smoothing & seeding.
ewm(alpha=1/period, adjust=False)is Wilder’s smoothing but seeds on the first value; the textbook ADX seeds with sums, so the first ~2×period bars differ slightly before converging — the same seeding nuance as the ATR and EMA. - Double lag. ADX is smoothed twice, so it’s slow by construction — treat it as a regime meter, not a trigger.
The lazy one-liner
import pandas_ta as ta
df.ta.adx(length=14, append=True) # adds ADX_14, DMP_14, DMN_14
Same indicator elsewhere: MQL5, Pine Script, EasyLanguage, NinjaScript.
This post is educational, not financial advice. Indicators describe the past; they don’t predict the future. Backtest anything before you risk real money on it.
Sources and further reading
- Windowing operations (pandas documentation)
