KhueApps
Home/Python/How to detect MACD positive/ negative divergence with Python

How to detect MACD positive/ negative divergence with Python

Last updated: October 05, 2025

Overview

This guide shows how to detect MACD positive (bullish) and negative (bearish) divergence in Python using pandas and NumPy. We compute MACD, extract price/oscillator swing points (pivots), align them, and flag divergences.

  • Positive (bullish) divergence: price makes a lower low, MACD makes a higher low.
  • Negative (bearish) divergence: price makes a higher high, MACD makes a lower high.

You can test on daily data from yfinance and adapt to intraday feeds.

Quickstart

  1. Load OHLC data and compute the MACD line (or histogram) with EMAs.
  2. Detect swing highs/lows in both price and MACD via a local window.
  3. Align price pivots with nearby MACD pivots within a tolerance of bars.
  4. Compare consecutive pivot pairs to flag divergences.
  5. Review events, tune window and tolerance, and integrate into backtests.

Minimal working example

# pip install pandas numpy yfinance
import numpy as np
import pandas as pd
import yfinance as yf

# ----- Indicators -----
def macd(close: pd.Series, fast=12, slow=26, signal=9):
    ema_fast = close.ewm(span=fast, adjust=False).mean()
    ema_slow = close.ewm(span=slow, adjust=False).mean()
    macd_line = ema_fast - ema_slow
    signal_line = macd_line.ewm(span=signal, adjust=False).mean()
    hist = macd_line - signal_line
    return macd_line, signal_line, hist

# ----- Pivot detection (swing points) -----
def pivots(series: pd.Series, window=5, kind='low') -> np.ndarray:
    # A pivot must be strictly lower/higher than its neighbors in a 2*window+1 range
    arr = series.values
    n = len(arr)
    out = []
    for i in range(window, n - window):
        segment = arr[i - window:i + window + 1]
        center = arr[i]
        if kind == 'low':
            if center == segment.min() and center < segment[:window].min() and center < segment[window+1:].min():
                out.append(i)
        else:  # 'high'
            if center == segment.max() and center > segment[:window].max() and center > segment[window+1:].max():
                out.append(i)
    return np.array(out, dtype=int)

# Map each price pivot to the nearest oscillator pivot within max_lag bars
def align_pivots(price_idxs: np.ndarray, osc_idxs: np.ndarray, max_lag=3):
    mapping = {}
    if len(osc_idxs) == 0:
        return mapping
    for pi in price_idxs:
        j = int(np.argmin(np.abs(osc_idxs - pi)))
        if abs(int(osc_idxs[j]) - int(pi)) <= max_lag:
            mapping[int(pi)] = int(osc_idxs[j])
    return mapping

# ----- Divergence detection -----
def find_divergences(close: pd.Series, osc: pd.Series, window=5, max_lag=3, lookback=300):
    price_lows = pivots(close, window, 'low')
    price_highs = pivots(close, window, 'high')
    osc_lows = pivots(osc, window, 'low')
    osc_highs = pivots(osc, window, 'high')

    low_map = align_pivots(price_lows, osc_lows, max_lag)
    high_map = align_pivots(price_highs, osc_highs, max_lag)

    events = []
    # Bullish divergence: price lower low, osc higher low
    low_keys = sorted(low_map.keys())
    for i in range(1, len(low_keys)):
        p1, p2 = low_keys[i-1], low_keys[i]
        if p2 < len(close) and p2 >= max(0, len(close) - lookback):
            if close.iloc[p2] < close.iloc[p1] and osc.iloc[low_map[p2]] > osc.iloc[low_map[p1]]:
                events.append({
                    'type': 'bullish',
                    'price_idx1': p1, 'price_idx2': p2,
                    'osc_idx1': low_map[p1], 'osc_idx2': low_map[p2]
                })

    # Bearish divergence: price higher high, osc lower high
    high_keys = sorted(high_map.keys())
    for i in range(1, len(high_keys)):
        p1, p2 = high_keys[i-1], high_keys[i]
        if p2 < len(close) and p2 >= max(0, len(close) - lookback):
            if close.iloc[p2] > close.iloc[p1] and osc.iloc[high_map[p2]] < osc.iloc[high_map[p1]]:
                events.append({
                    'type': 'bearish',
                    'price_idx1': p1, 'price_idx2': p2,
                    'osc_idx1': high_map[p1], 'osc_idx2': high_map[p2]
                })

    return events

# ----- Example run -----
# Download daily data, compute MACD, and find divergences
symbol = 'AAPL'
df = yf.download(symbol, period='2y', interval='1d', auto_adjust=True, progress=False)
close = df['Close']
macd_line, signal_line, hist = macd(close)
osc = macd_line  # you can try 'hist' as well

events = find_divergences(close, osc, window=5, max_lag=3, lookback=350)

# Show the last few events
for e in events[-5:]:
    p1d = close.index[e['price_idx1']]
    p2d = close.index[e['price_idx2']]
    print(
        e['type'].upper(),
        'from', p1d.date(), 'to', p2d.date(),
        '| price:', round(float(close.iloc[e['price_idx1']]), 2), '→', round(float(close.iloc[e['price_idx2']]), 2),
        '| MACD:', round(float(osc.iloc[e['osc_idx1']]), 4), '→', round(float(osc.iloc[e['osc_idx2']]), 4)
    )

What counts as divergence

DivergencePrice pivotsMACD pivots
Positive (bullish)Lower low (LL)Higher low (HL)
Negative (bearish)Higher high (HH)Lower high (LH)

Tip: You can use either the MACD line or the histogram. The histogram often reacts faster but is noisier.

Step-by-step algorithm

  1. Compute MACD line: EMA(fast) − EMA(slow). Optionally compute the histogram: MACD − signal.
  2. Find pivots for price and MACD with a symmetric window of k bars on each side.
  3. Align each price pivot to the nearest MACD pivot within max_lag bars (tolerance).
  4. Iterate through consecutive price pivots of the same kind (two lows or two highs).
  5. Compare directions: LL + HL → bullish; HH + LH → bearish. Record event indices and dates.

Tuning parameters

  • window: how strict a pivot is (larger = fewer, more reliable pivots). Start with 3–7.
  • max_lag: maximum bar distance to match MACD pivot to price pivot (2–5 typical).
  • MACD variant: try macd_line vs hist. Histogram may surface earlier signals.
  • lookback: limit processing to recent bars for speed.

Interpreting results

  • Divergence suggests potential momentum exhaustion, not guaranteed reversals.
  • Combine with confirmation (trend filters, break of structure, volume, or RSI).
  • Expect multiple signals; filter by trend regime or minimum MACD amplitude difference.

Pitfalls to avoid

  • Repainting pivots: a pivot is only confirmed after window bars pass. Do not act on the current bar’s partial pivot.
  • Overfitting window/max_lag to a single asset or timeframe.
  • Misalignment: matching pivots with no time tolerance creates false negatives; too large tolerance creates false positives.
  • Equal highs/lows: flat pivots can confuse strict comparisons. Consider ≥ or ≤ if needed.
  • Using highs/lows vs closes: the code uses closes; for wick-based pivots, switch to High/Low.
  • Regime dependency: divergences in strong trends can fail repeatedly.

Performance notes

  • Complexity: O(n * window) due to pivot scan loops. With small window, this is fast for typical daily/intraday datasets.
  • Vectorization: For very large datasets, consider vectorized extrema detection using rolling windows or stride tricks, or use numba to JIT the pivot loop.
  • Memory: Keep only arrays you need (close and chosen oscillator). Avoid copying full OHLC if unused.
  • Batch processing: Precompute EMAs once per symbol; reuse for multiple scans.
  • Streaming: Maintain rolling buffers and update EMA incrementally to avoid recomputing from scratch.

Variations and extensions

  • Confirmation filter: require MACD cross of signal after divergence.
  • Strength filter: enforce min distance between pivots or min MACD difference.
  • Multi-timeframe: confirm a 5m divergence with a 1h trend filter.
  • Histogram-based: simply set osc = hist in the example to switch.

FAQ

  • Which is better, MACD line or histogram?

    • Histogram reacts sooner but is noisier. MACD line is smoother. Test both.
  • Why do signals appear late?

    • Pivots are confirmed only after window bars close; this prevents repainting but adds latency.
  • Can this run intraday?

    • Yes. Use intraday intervals and adjust window and max_lag to the timeframe’s noise level.
  • How do I reduce false positives?

    • Increase window, require larger MACD differences, add trend/volume filters, and confirm with price action.

Next Article: Generating Real-Time Trading Signals with yfinance and Python

Series: Algorithmic Trading with Python

Python