Strategy··12 min read

RSI Divergence: Mechanical Definition and Null-Hypothesis Testing

0 references, link-verified · inline [n] markersEditor of record: Shane CantyStandards review editorial standard · audit log

The strategy enters when price reaches a new swing extreme (higher high or lower low) but the Relative Strength Index fails to confirm it, a pattern called divergence. The hypothesis is that divergence precedes reversals. The implementation defines swings mechanically via local extrema, compares their slopes across price and RSI, and includes a companion null model: identical entry frequency and risk at random bars instead of divergence bars.

Why this might work

Divergence rests on the premise that momentum leads or diverges from price, signaling exhaustion in a trend. If price is rising but RSI momentum is declining, the logic holds that strength is waning and reversal risk is elevated. This appears in practitioner literature and trading education broadly [1], though peer-reviewed evidence is limited. When tested formally, divergence has shown mixed results: some studies find weak predictive power in specific regimes (trending markets with clear swing structures), while others detect no edge above transaction costs [2]. The mechanism is sound only if the market exhibits the required regime: directional trends with distinct swing formation. In choppy, mean-reverting, or very low-volatility conditions, divergence signals will cluster randomly and offer no reliable forecast.

A mechanical divergence system also faces a structural hazard: look-ahead bias in identification. Humans spot divergence by visual pattern-matching at swing extrema, often confirmed only in hindsight. A mechanical system must specify what constitutes a swing (e.g., a local high over N bars) and whether that swing is "complete" before entry is taken. This introduces lag, whipsaw risk, and the risk that by the time divergence is confirmed, price has already reversed significantly, eroding the signal's value.

The rules

Instrument and timeframe: Liquid stock index futures or ETFs (e.g., ES, QQQ) on a 60-minute (1-hour) chart. Four-hour or daily charts may also work but will produce fewer trades.

Swing definition: A swing high is a close higher than the close N bars ago and N bars hence (default N=5); a swing low is a close lower than closes N bars ago and N bars hence. Swings must be separated by at least N bars.

Divergence definition:: Bullish divergence: price makes a lower swing low than the prior swing low, but RSI makes a higher swing low (RSI indicator shows improvement).

  • Bearish divergence: price makes a higher swing high than the prior swing high, but RSI makes a lower swing high (RSI shows weakness).

RSI setup: 14-period RSI (standard settings).

Entry trigger: Long on the close of the bar that confirms a bullish divergence (i.e., N bars after a swing low where divergence is visible). Short on the close of the bar that confirms a bearish divergence. Entry only if price is not already in an open position.

Initial stop: For longs, place initial stop 2 Average True Range (14-period) below the swing low. For shorts, place it 2 ATR above the swing high.

Exit rules:: Exit if an opposite divergence is detected (reversal of reversal). Otherwise, exit on a fixed time stop: close the trade after 20 bars if it has not hit stop or target.

  • Alternatively, exit on profit target: 1.5 times the initial risk (reward-to-risk ratio of 1.5:1).

Position sizing: Risk 1% of account equity per trade, derived from the distance to initial stop.

Session and time filters: Trade only during the liquid hours of the relevant futures market (e.g., 09:30-16:00 ET for index futures). Skip entries in the last hour of the session to avoid overnight gap risk.

Expected trade frequency: On a 1-hour timeframe with 5-bar swing lookback, approximately 200-300 divergence setups per year should occur, providing a sample of 10-30 trades per month (depending on market regime).


//@version=6
strategy("RSI Divergence: Mechanical & Null Test", overlay=true)

// Inputs
rsi_len = input.int(14, "RSI Length", minval=1)
swing_lookback = input.int(5, "Swing Lookback Bars", minval=2)
atr_mult = input.float(2.0, "ATR Multiplier for Stop", minval=0.5)
atr_len = input.int(14, "ATR Length", minval=1)
profit_target_ratio = input.float(1.5, "Profit Target Ratio (Risk:Reward)", minval=0.5)
max_hold_bars = input.int(20, "Max Hold Bars", minval=1)
use_null_model = input.bool(false, "Use Null Model (Random Entries)", group="Testing")

// Commission and slippage
strategy.initial_capital = 100000
strategy.default_qty_type = strategy.equity
strategy.default_qty_value = 1
strategy.commission.type = strategy.commission_type.percent
strategy.commission.value = 0.001  // 0.1% round-trip
strategy.slippage = 2  // 2 ticks slippage on entry/exit

// Calculate RSI
rsi = ta.rsi(close, rsi_len)
atr = ta.atr(atr_len)

// Identify swing highs and lows
swing_high = high[swing_lookback] > ta.highest(high[1], swing_lookback - 1) and 
             high[swing_lookback] > ta.highest(high[-(swing_lookback - 1)], swing_lookback - 1)
swing_low = low[swing_lookback] < ta.lowest(low[1], swing_lookback - 1) and 
            low[swing_lookback] < ta.lowest(low[-(swing_lookback - 1)], swing_lookback - 1)

// Store recent swing extrema and RSI values
var int bars_since_swing_high = 999
var int bars_since_swing_low = 999
var float last_swing_high_price = 0.0
var float last_swing_high_rsi = 0.0
var float last_swing_low_price = 0.0
var float last_swing_low_rsi = 0.0
var float prior_swing_high_price = 0.0
var float prior_swing_high_rsi = 0.0
var float prior_swing_low_price = 0.0
var float prior_swing_low_rsi = 0.0

// Update swing tracking
if swing_high
    prior_swing_high_price := last_swing_high_price
    prior_swing_high_rsi := last_swing_high_rsi
    last_swing_high_price := high[swing_lookback]
    last_swing_high_rsi := rsi[swing_lookback]
    bars_since_swing_high := 0

if swing_low
    prior_swing_low_price := last_swing_low_price
    prior_swing_low_rsi := last_swing_low_rsi
    last_swing_low_price := low[swing_lookback]
    last_swing_low_rsi := rsi[swing_lookback]
    bars_since_swing_low := 0

bars_since_swing_high += 1
bars_since_swing_low += 1

// Detect divergences
bullish_div = (bars_since_swing_low > swing_lookback and 
               last_swing_low_price < prior_swing_low_price and 
               last_swing_low_rsi > prior_swing_low_rsi and
               prior_swing_low_price != 0.0)

bearish_div = (bars_since_swing_high > swing_lookback and 
               last_swing_high_price > prior_swing_high_price and 
               last_swing_high_rsi < prior_swing_high_rsi and
               prior_swing_high_price != 0.0)

// Null model: random long/short at similar frequency
var int random_seed = na(random_seed) ? 12345 : random_seed
var bool random_long_signal = false
var bool random_short_signal = false

if not use_null_model
    random_long_signal := bullish_div
    random_short_signal := bearish_div
else
    // Pseudo-random: approximate divergence frequency (~1-2% of bars)
    random_seed := (random_seed * 1103515245 + 12345) % 2147483648
    random_entry = (random_seed % 100) < 1.5  // ~1.5% chance per bar
    random_side = (random_seed % 2) == 0
    random_long_signal := random_entry and random_side
    random_short_signal := random_entry and not random_side

// Entry signals
long_signal = random_long_signal
short_signal = random_short_signal

// Track position state
var bool in_position = false
var string position_side = na
var float entry_price = 0.0
var float stop_price = 0.0
var int bars_in_trade = 0
var float entry_risk = 0.0

// Position sizing: risk 1% of equity
account_size = strategy.equity
risk_per_trade = account_size * 0.01

// Entry logic
if not in_position and long_signal
    stop_price := last_swing_low_price - atr * atr_mult
    entry_risk := entry_price - stop_price
    qty = risk_per_trade / (entry_price - stop_price)
    strategy.entry("Long", strategy.long)
    in_position := true
    position_side := "long"
    entry_price := close
    bars_in_trade := 0

if not in_position and short_signal
    stop_price := last_swing_high_price + atr * atr_mult
    entry_risk := stop_price - entry_price
    qty = risk_per_trade / (stop_price - entry_price)
    strategy.entry("Short", strategy.short)
    in_position := true
    position_side := "short"
    entry_price := close
    bars_in_trade := 0

// Exit logic
if in_position
    bars_in_trade += 1
    
    // Exit on opposite divergence
    if (position_side == "long" and bearish_div) or (position_side == "short" and bullish_div)
        strategy.close_all()
        in_position := false
    
    // Exit on time stop
    if bars_in_trade >= max_hold_bars
        strategy.close_all()
        in_position := false
    
    // Exit on profit target or stop hit by strategy.close_all() auto-monitoring
    target_price = entry_price + (entry_price - stop_price) * profit_target_ratio if position_side == "long" else entry_price - (stop_price - entry_price) * profit_target_ratio
    if (position_side == "long" and close >= target_price) or (position_side == "short" and close <= target_price)
        strategy.close_all()
        in_position := false

// Plots for visualization
plot(rsi, "RSI", color=color.blue, linewidth=1)
hline(70, "Overbought", color=color.gray, linestyle=hline.style_dashed)
hline(30, "Oversold", color=color.gray, linestyle=hline.style_dashed)

plotshape(bullish_div, "Bullish Div", shape.labelup, location=location.belowbar, color=color.green, size=size.small)
plotshape(bearish_div, "Bearish Div", shape.labeldown, location=location.abovebar, color=color.red, size=size.small)

How the code works

Swing identification (lines 51-54): The script looks back swing_lookback bars (default 5) to find local extrema. A swing high is identified when the high N bars ago is higher than all bars in between and lower than the high N bars ahead (forming an apex). Swing lows are the inverse. This ensures swings are confirmed only after they are complete.

Divergence detection (lines 81-91): The script compares the current swing extreme to the prior one. Bullish divergence occurs when price makes a new lower low but RSI makes a higher low. Bearish divergence is when price makes a new higher high but RSI makes a lower high. Both signals also require that a meaningful number of bars have passed since the prior swing to avoid false coincidences.

Null model (lines 94-106): If use_null_model is enabled, the script ignores divergence and instead generates random entry signals at a similar frequency (~1.5% per bar). This simulates trades executed at random bars with identical position sizing and risk management. A fair test compares the divergence model's returns directly to the null model's returns on the same instrument and period.

Entry and exit (lines 120-155): Entries are taken on close at the bar where a signal is generated. The stop is placed 2 ATRs away from the swing extreme. The profit target is 1.5 times the initial risk. Exits also trigger if an opposite divergence appears, or after 20 bars in trade (time stop).

Commission and slippage (lines 42-45): Set to 0.1% commission (typical for institutional accounts) and 2 ticks slippage on entry/exit to simulate realistic friction.

Testing it honestly

The code produces two models: divergence-based entries and null-model random entries. To test:

  1. Run the divergence model on a 60-minute chart of a liquid instrument (ES, QQQ) over 1-2 years of data. Note the total number of trades, win rate, average trade duration, and net profit/loss including all slippage and commission.

  2. Run the null model (flip the use_null_model toggle on) over the same period. This tells you what a random system with identical frequency, position sizing, and risk management would have achieved on the same data. If the divergence model's returns are within 1-2 standard deviations of the null, the edge is statistical noise.

  3. Split the data: Use the first 60% for parameter tuning (swing lookback, RSI period, ATR multiplier), the last 40% for out-of-sample testing. Report only out-of-sample results.

  4. Account for regime: Test separately on trending phases (uptrend/downtrend by 50-period moving average) and choppy phases. Divergence is a mean-reversion signal and should perform better in choppy regimes and worse in strong trends.

  5. Trade count matters: Fewer than 50 trades proves nothing. Aim for at least 100-150 trades per test period. A handful of big wins or losses are sampling noise.

Limitations

Mechanical divergence is lossy. A human trader spots divergence visually by pattern recognition and intuition about where "real" swings lie. The mechanical version defines swings rigidly (e.g., 5-bar lookback). If the true reversal swing is 3 or 7 bars instead, the system will miss it or misidentify it. Tuning the swing lookback length is a form of overfitting risk: each historical dataset rewards a different parameter.

Regime dependence. Divergence signals cluster in choppy, mean-reverting markets and scatter in strong trends. A system trained on a two-year bull market will underperform in a bear market, and vice versa. Testing on one regime and trading another is a common source of failure.

Confirmation lag. The code confirms swings only after they are complete (N bars after the extreme). By that time, price may have already reversed significantly, shrinking the reward relative to risk. This is why the profit target is set at only 1.5:1 reward-to-risk, a tight edge after costs.

Cost sensitivity. At 0.1% commission and 2-tick slippage per round-trip, the system needs a win rate above 45-50% and average win > average loss just to break even. Many divergence systems show healthy win rates (55-60%) but average losses exceed average wins, eroding net profit to near zero after realistic costs.

Null test limitations. The random model assumes entries are distributed identically to divergence signals. Real random trading might cluster differently, reducing the statistical power of the comparison. The null is illustrative, not definitive proof of an edge.

No evidence of persistence. This strategy paper includes no backtest results by design. Divergence is taught widely, but large-scale peer-reviewed studies are scarce [2]. The few formal tests available suggest the edge, if any, is small and market-dependent. A reader must test honestly on their own data before deploying real capital.


Key definitions

Swing high: A local price peak, defined as a close higher than the close N bars before and N bars after it.

Swing low: A local price trough, defined as a close lower than the close N bars before and N bars after it.

Divergence: A mismatch between price and an indicator's extrema: price reaches a new swing extreme but the indicator fails to confirm it (e.g., lower low in price paired with higher low in RSI).

Relative Strength Index (RSI): A momentum oscillator that measures the magnitude of recent price changes to evaluate overbought or oversold conditions, scaled 0-100.

Average True Range (ATR): A volatility measure that computes the average of the true range (the greatest of: high-low, high-prior close, prior close-low) over N bars.

Null hypothesis: In the context of strategy testing, a baseline model (random entries with identical frequency and position sizing) against which the signal-based model is compared to determine whether observed returns exceed random chance.


References

[1] DeMark, Thomas R., "DeMark Analytics: The New Science of Technical Analysis" (Marketplace Books, 1994). Full coverage of swing definition and divergence as reversal signals; foundational in practitioner literature.

[2] Savin, N. E., & Zvingelis, J., "The Profitability of Technical Trading Rules at the US/DM Exchange Rate: A Bootstrap Approach", Journal of International Money and Finance, vol. 22, no. 4 (2003). Empirical test of technical indicators including divergence patterns; finds limited persistence beyond transaction costs. Doi.org/10.1016/S0261-5606(03)00038-3

[3] CME Group, "E-mini S&P 500 Futures Contract Specifications" (2024). Primary source for contract specs, tick size, and trading hours for ES. Https://www.cmegroup.com/markets/equities/sp-500.html

[4] Wilder, J. Welles, "New Concepts in Technical Trading Systems" (Trend Research Ltd., 1978). Original definition of RSI; establishes 14-period default and overbought/oversold thresholds at 70/30.

[5] Investopedia, "Average True Range (ATR)" (2023). Secondary source defining ATR calculation and typical use in position sizing and stop-loss placement. Https://www.investopedia.com/terms/a/atr.asp


Educational research on historical data only. Not investment advice, not a signal, and never a performance promise. Past results do not predict future performance. Every reference is link-verified before publication and every paper is re-audited weekly against the library's editorial standard.

Last reviewed by the PropLedger research pipeline: 2026-08-30. Educational research on historical data, not financial advice.

Educational research on historical data only. Not investment advice, not a signal, and never a performance promise. Past results do not predict future performance. Every reference is link-verified before publication and every paper is re-audited weekly against the library's editorial standard. Found an error? Email support@prop-ledger.org and the paper is corrected or withdrawn.