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
- Load OHLC data and compute the MACD line (or histogram) with EMAs.
- Detect swing highs/lows in both price and MACD via a local window.
- Align price pivots with nearby MACD pivots within a tolerance of bars.
- Compare consecutive pivot pairs to flag divergences.
- 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
| Divergence | Price pivots | MACD 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
- Compute MACD line: EMA(fast) − EMA(slow). Optionally compute the histogram: MACD − signal.
- Find pivots for price and MACD with a symmetric window of k bars on each side.
- Align each price pivot to the nearest MACD pivot within max_lag bars (tolerance).
- Iterate through consecutive price pivots of the same kind (two lows or two highs).
- 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.