ADX / DMI (trend strength)

Every indicator so far answers "which direction?" ADX answers a different, arguably more important question: "is there a trend at all, worth trading with a trend-following indicator?" This is the filter chapters 102 and 104 already referenced.

def adx_dmi(df: pd.DataFrame, period: int = 14) -> pd.DataFrame:
    high, low, close = df["high"], df["low"], df["close"]
    prev_close, prev_high, prev_low = close.shift(1), high.shift(1), low.shift(1)

    tr = pd.concat([high - low, (high - prev_close).abs(), (low - prev_close).abs()], axis=1).max(axis=1)

    up_move = high - prev_high
    down_move = prev_low - low
    plus_dm = pd.Series(0.0, index=df.index)
    minus_dm = pd.Series(0.0, index=df.index)
    plus_dm[(up_move > down_move) & (up_move > 0)] = up_move
    minus_dm[(down_move > up_move) & (down_move > 0)] = down_move

    atr_smooth = tr.ewm(alpha=1/period, adjust=False).mean()
    plus_di = 100 * plus_dm.ewm(alpha=1/period, adjust=False).mean() / atr_smooth
    minus_di = 100 * minus_dm.ewm(alpha=1/period, adjust=False).mean() / atr_smooth

    dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di)
    adx = dx.ewm(alpha=1/period, adjust=False).mean()

    return pd.DataFrame({"plus_di": plus_di, "minus_di": minus_di, "adx": adx}, index=df.index)

Reading the three lines

  • +DI / -DI — directional indicators; +DI above -DI means upward pressure dominates, and vice versa. Their crossover is itself a tradeable trend-direction signal (similar structure to chapter 102's MACD crossover).
  • ADX — trend *strength*, direction-agnostic (it doesn't tell you up or down, only how strongly *whichever* direction is trending).

Standard ADX interpretation bands

def trend_strength_label(adx_value: float) -> str:
    if adx_value < 20:
        return "no trend / ranging"
    elif adx_value < 40:
        return "developing or moderate trend"
    else:
        return "strong trend"

The primary use: gating trend-following signals

def gated_macd_signal(macd_df: pd.DataFrame, adx_df: pd.DataFrame, adx_threshold: float = 20) -> pd.Series:
    raw_signal = macd_crossover_signal(macd_df)   # chapter 102
    trending = adx_df["adx"] > adx_threshold
    return raw_signal.where(trending, 0)   # suppress signals when ADX says there's no trend to follow

This .where(condition, 0) pattern — take a raw signal, zero it out when a filter condition fails — is the simplest form of the AND-based condition combination formalized in chapter 115.

Common mistake: using ADX to time entries directly

ADX rising from 15 to 25 doesn't tell you *when* within that rise to enter — it's a filter/context indicator, not an entry trigger on its own. Pair it with a directional signal (MACD, EMA crossover, +DI/-DI crossover) that provides the actual entry timing.

Next: 107 — ATR and Supertrend