Overview
This guide shows how to automatically recognize RSI positive (bullish) and negative (bearish) divergence in Python. We compute RSI, find swing highs/lows (pivots), and pair pivots to identify divergences in a reproducible, backtest-friendly way.
Definitions:
- Bullish (positive) divergence: price makes a lower low while RSI makes a higher low.
- Bearish (negative) divergence: price makes a higher high while RSI makes a lower high.
We use only pandas and numpy for portability.
Quickstart
- Install dependencies
- pip install pandas numpy
- Prepare a Series of close prices (float index or DateTime index).
- Compute RSI (Wilder’s smoothing).
- Detect pivots (swing highs/lows) using left/right window sizes.
- Pair recent pivots to confirm divergence within a lookback window.
- Use the resulting signals for alerts, plots, or backtests.
Minimal working example
import numpy as np
import pandas as pd
# 1) Sample data (synthetic). Replace df['close'] with your price series.
np.random.seed(0)
n = 800
ret = np.random.normal(0, 0.5, n) / 100 # ~0.5% daily volatility
close = 100 * (1 + pd.Series(ret)).cumprod()
df = pd.DataFrame({'close': close})
# 2) RSI (Wilder) implementation
def rsi(series: pd.Series, period: int = 14) -> pd.Series:
delta = series.diff()
gain = delta.clip(lower=0.0)
loss = -delta.clip(upper=0.0)
avg_gain = gain.ewm(alpha=1/period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1/period, adjust=False).mean()
rs = avg_gain / (avg_loss.replace(0, np.nan))
rsi = 100 - (100 / (1 + rs))
return rsi.fillna(50) # neutral until warm-up completes
# 3) Pivot detection (left/right bars for confirmation)
def pivots_high(series: pd.Series, left: int = 3, right: int = 3) -> pd.Series:
arr = series.values
n = len(arr)
out = np.zeros(n, dtype=bool)
for i in range(left, n - right):
if arr[i] == np.max(arr[i-left:i+right+1]) and arr[i] > np.max(arr[i-left:i]):
out[i] = True
return pd.Series(out, index=series.index)
def pivots_low(series: pd.Series, left: int = 3, right: int = 3) -> pd.Series:
arr = series.values
n = len(arr)
out = np.zeros(n, dtype=bool)
for i in range(left, n - right):
if arr[i] == np.min(arr[i-left:i+right+1]) and arr[i] < np.min(arr[i-left:i]):
out[i] = True
return pd.Series(out, index=series.index)
# 4) Divergence detection
def detect_divergence(
close: pd.Series,
rsi_series: pd.Series,
piv_low: pd.Series,
piv_high: pd.Series,
lookback: int = 150,
min_rsi_diff: float = 2.0, # min RSI separation (points)
min_price_pct: float = 0.2, # min price separation (% of price)
require_rsi_extremes: bool = True,
) -> pd.DataFrame:
idx = close.index
bull = pd.Series(0, index=idx, dtype=int)
bear = pd.Series(0, index=idx, dtype=int)
low_idx = np.where(piv_low.values)[0]
high_idx = np.where(piv_high.values)[0]
# Bullish: lower price low, higher RSI low
for k in range(1, len(low_idx)):
i2 = low_idx[k]
i1 = low_idx[k - 1]
if i2 - i1 > lookback:
continue
p1, p2 = close.iat[i1], close.iat[i2]
r1, r2 = rsi_series.iat[i1], rsi_series.iat[i2]
price_lower_low = p2 < p1 * (1 - min_price_pct / 100)
rsi_higher_low = r2 > r1 + min_rsi_diff
if not price_lower_low:
# allow equal-or-slightly-lower low with tolerance
price_lower_low = (p2 < p1) and (abs(p2 - p1) / p1 * 100 >= min_price_pct)
if require_rsi_extremes:
cond_extreme = (r1 < 50) and (r2 < 60)
else:
cond_extreme = True
if price_lower_low and rsi_higher_low and cond_extreme:
bull.iat[i2] = 1
# Bearish: higher price high, lower RSI high
for k in range(1, len(high_idx)):
i2 = high_idx[k]
i1 = high_idx[k - 1]
if i2 - i1 > lookback:
continue
p1, p2 = close.iat[i1], close.iat[i2]
r1, r2 = rsi_series.iat[i1], rsi_series.iat[i2]
price_higher_high = p2 > p1 * (1 + min_price_pct / 100)
rsi_lower_high = r2 < r1 - min_rsi_diff
if not price_higher_high:
price_higher_high = (p2 > p1) and (abs(p2 - p1) / p1 * 100 >= min_price_pct)
if require_rsi_extremes:
cond_extreme = (r1 > 50) and (r2 > 40)
else:
cond_extreme = True
if price_higher_high and rsi_lower_high and cond_extreme:
bear.iat[i2] = -1
return pd.DataFrame({'bull_div': bull, 'bear_div': bear})
# 5) Run pipeline
df['rsi'] = rsi(df['close'], period=14)
df['piv_high'] = pivots_high(df['close'], left=3, right=3)
df['piv_low'] = pivots_low(df['close'], left=3, right=3)
signals = detect_divergence(
df['close'], df['rsi'], df['piv_low'], df['piv_high'],
lookback=150, min_rsi_diff=2.0, min_price_pct=0.2, require_rsi_extremes=True
)
df = pd.concat([df, signals], axis=1)
df['signal'] = df['bull_div'] + df['bear_div'] # 1 bullish, -1 bearish, 0 none
print(df[['close','rsi','piv_low','piv_high','signal']].tail(10))
print('\nCounts:', df['signal'].value_counts().to_dict())
Notes
- The pivot right-window waits for confirmation, preventing repainting during backtests.
- For live use, consider i-1 detection (no right window) and expect more noise.
Algorithm steps
- Compute RSI on close.
- Identify swing lows/highs with left/right pivot windows.
- For each new pivot, search the previous pivot of the same type within a lookback window.
- Bullish: price lower low and RSI higher low; Bearish: price higher high and RSI lower high.
- Optional filters: require RSI to be below/above midline to reduce false positives.
- Emit signals on the bar where the pivot is confirmed (after right bars elapse).
Recommended parameters
| Parameter | Typical range | Default |
|---|---|---|
| RSI period | 10–21 | 14 |
| Pivot left/right | 2–5 | 3 |
| Lookback (bars) | 50–250 | 150 |
| Min RSI diff (pts) | 1–5 | 2 |
| Min price diff (%) | 0.1–1.0 | 0.2 |
Adjust per instrument and timeframe.
Using with your own data
- CSV: df = pd.read_csv('prices.csv', parse_dates=['date'], index_col='date'); df['close'] = df['Close']
- OHLC data: apply RSI to the close; pivots also use close by default. You can pivot highs/lows using high/low columns, but compare RSI from close.
Example tweak for OHLC pivots:
# Use H/L for pivots, close for RSI
piv_high = pivots_high(df['high'], left=3, right=3)
piv_low = pivots_low(df['low'], left=3, right=3)
signals = detect_divergence(df['close'], df['rsi'], piv_low, piv_high)
Pitfalls and validation
- Repainting: Using a right window delays signals. Without it, pivots can disappear; never backtest with future-aware pivots.
- Parameter sensitivity: Small changes can dramatically alter counts. Cross-validate on multiple symbols/timeframes.
- Equal highs/lows: Ties near pivots can create duplicates. Use strict vs non-strict comparisons carefully.
- Regime bias: Divergences fail in strong trends. Consider trend filters (e.g., 200-SMA direction) or require RSI crosses.
- Data quality: Missing bars and corporate actions can distort RSI and pivots. Clean and align data.
Performance notes
- Complexity: O(n) for RSI and pivot scans; O(m) for pivot pairings (m << n). Suitable for millions of bars with vectorized ops and minimal Python loops.
- Speedups:
- Prefer numpy arrays inside tight loops (as shown).
- Reduce pivot density by larger left/right windows.
- Batch-process multiple symbols by column-wise operations.
- Consider numba for the pivot loops if you profile a hotspot.
Extensions
- Confirmations: Require RSI to cross above 50 (bullish) or below 50 (bearish) after divergence.
- Multi-pivot: Compare more than two pivots to reduce noise.
- Risk management: Place stops beyond the most recent swing; test fixed-R or ATR-based.
Tiny FAQ
Q: What RSI period should I use? A: 14 is common. Shorter (7–10) is more sensitive; longer (21–28) is smoother.
Q: Can I detect hidden divergence? A: Yes. Adapt conditions: bullish hidden uses higher low in price with lower low in RSI; bearish hidden uses lower high in price with higher high in RSI.
Q: Why are there few signals? A: Increase lookback, reduce min_price_pct/min_rsi_diff, or use smaller pivot windows. Expect trade-off with noise.
Q: How do I avoid repainting? A: Keep a nonzero right window for pivots. Emit signals only when the pivot is confirmed.