Pine Script

Pine Script RSI/MACD Divergence: Harder to Code

2026-09-28·10 min read

You spot an obvious divergence on the chart, flip back through history, and four out of five look right. The moment you translate that same logic into Pine Script — TradingView's built-in strategy scripting language — and paste it in, the alert either stays silent, fires before the bar closes, or shows up five bars later than your eye caught it.

The problem isn't you and it isn't Pine's syntax. Divergence is a thing that lives in two different worlds — **retrospective inspection** and **per-bar real-time judgement** — and those worlds don't line up. Your eye can scan the whole chart backwards and cherry-pick a pivot; a script standing on the current bar only knows what's happened up to that point. This post won't re-explain what divergence means (the RSI and MACD guides already do), and it won't hand you a "stable divergence strategy" — that needs backtesting and parameter search, more than one article can honestly promise. It covers three things only: where the eye-vs-code gap actually lives, what the minimum viable Pine implementation looks like, and the traps waiting for you once the alert leaves the script.

TL;DR
One: `ta.pivothigh(source, left, right)` needs the "next right bars" to be lower than the pivot before it confirms, so any pivot you can see on the current bar is already right bars in the past — right=5 means a 5-bar lag. Two: the real-time RSI/MACD reading keeps changing until the bar closes, so gate the signal on `barstate.isconfirmed`, or the same bar will fire multiple contradictory alerts. Three: Pine won't remember "the previous pivot" for you — you have to hold it in a `var`, or every bar just compares against itself.

Gap one: the "previous high" your eye sees isn't visible to the script yet

The way your eye judges divergence is to scan left, pick a high that clearly stands out, then check whether today's high beats it and whether RSI followed. That move rests on a very unfriendly assumption for a script: you already know that point was a high.

The script's only real tool is ta.pivothigh(source, leftBars, rightBars), and the semantics of that function are "leftBars bars to the left are all lower, and rightBars bars to the right are all lower." That means it has to wait rightBars bars past the pivot before it can confirm anything — the pivot position you receive on the current bar is actually something that happened rightBars bars ago. That's design, not a bug.

5 bars
confirmation lag when right=5
na
returned on any bar that hasn't confirmed a pivot yet
2
pivots divergence needs at minimum (previous + current)

Put another way: if you want to compare "is this pivot high lower than the previous pivot high," the earliest you can do it is the bar on which this pivot just got confirmed — which is rightBars bars later than the true high. left and right are typically set equal (3 to 5 are common values); larger values mean longer lag but cleaner signals.

Gap two: signals before barstate.isconfirmed don't count

RSI, MACD, and their friends recalculate on the current bar as the close ticks. What you see as "RSI 68" right now might read 71 or 65 a second later — so on a single bar your divergence check can flip back and forth and fire an alert each time. This is repainting, the trap that Pine tutorials warn about repeatedly and that Pine users still fall into.

There is exactly one fix: gate the "is this condition true" check on barstate.isconfirmed (meaning the bar has closed and the value won't change again). Or, more conservatively, judge off the previous bar with close[1], rsi[1] — that's and barstate.isconfirmed or and not barstate.isrealtime in Pine.

This is the same class of problem as the one covered in Why Pine Script Alerts Don't Remember Anything: every bar in Pine is an independent calculation, and if you don't explicitly tell it "wait until this bar settles," it will happily push signals built from mid-bar values that disappear seconds later.

Gap three: Pine doesn't automatically remember the "previous pivot"

Every call to ta.pivothigh() answers "does this bar have a newly confirmed pivot" — it returns the pivot value if yes and na if not. It does not store "the previous pivot value and where it happened."

To judge divergence you need at least two pivot highs to compare: the previous one and the current one. Pine gives you two ways to do it:

  • Hold it yourself in a var: declare var float lastHigh = na, and whenever a new pivot comes in, move the old value out and put the new one in.
  • ta.valuewhen(cond, source, occurrence): ask directly "what was source the previous time cond was true."

Both work. ta.valuewhen is shorter but slower; the var version is more direct and easier to debug. The next section demos the var approach so it lines up with the code in tvsbot's existing RSI Indicator Complete Guide and MACD Indicator Complete Guide, which you can just paste in.

Minimum viable implementation: RSI bullish divergence

First, the shape we're after: price makes a new low but RSI does not — that's a bullish divergence, hinting that downside momentum is fading.

Set up the inputs and the RSI value:

pine
//@version=5
        indicator("RSI Bullish Divergence (minimal)", overlay=true)

        rsiLen = input.int(14, "RSI length")
        left   = input.int(5,  "Pivot left bars")
        right  = input.int(5,  "Pivot right bars")

        rsi = ta.rsi(close, rsiLen)
        

left and right are usually set to the same value — 5 here, meaning a pivot needs 5 bars on each side that are higher/lower than it; in other words, a 5-bar confirmation lag. Then find the pivots and remember the previous one:

pine
// pivot low confirms only after 'right' more bars; what you get here is a low from 'right' bars ago
        pl     = ta.pivotlow(low, left, right)
        pl_rsi = ta.pivotlow(rsi, left, right)

        // Store the previously confirmed pivots so the current one has something to compare to
        var float prevPriceLow = na
        var float prevRsiLow   = na
        

var is Pine's persistent-variable declaration: it initializes exactly once on the first bar, and every subsequent bar can overwrite it. Then judge the divergence and plot it:

pine
bullishDiv = false
        if not na(pl) and not na(pl_rsi) and not na(prevPriceLow) and not na(prevRsiLow)
            // price lower, RSI higher → bullish divergence
            bullishDiv := pl < prevPriceLow and pl_rsi > prevRsiLow

        // If this bar has a new confirmed pivot, promote "current" into "previous"
        if not na(pl)
            prevPriceLow := pl
        if not na(pl_rsi)
            prevRsiLow := pl_rsi

        // Only plot on bar close so intermediate values don't paint and disappear
        plotshape(bullishDiv and barstate.isconfirmed, "Bull Div",
                 shape.triangleup, location.belowbar,
                 color.new(color.green, 0), size = size.small)
        

This code is **minimum viable**, not "ready for live trading." It will miss detections and it will misfire — that's inherent to divergence detection, not a defect of the implementation. What to do before it touches an account is covered in the next section and the FAQ.

Three implementation traps, side by side

Eighty percent of the bugs in a divergence script fall into these three rows. The left column is the naive way, the right column is the version that works.

SituationCommon (broken) approachThe right way
Finding the previous highUse something like high[10] to guess a fixed lookbackUse ta.pivothigh for a confirmed pivot, then use var to remember the previous one
Firing before the bar closesFire as soon as the condition holds, no barstate.isconfirmedWrap the condition in and barstate.isconfirmed so each bar counts once
Alert frequencyUse alertcondition and let the default fire every time the condition holdsalert() with alert.freq_once_per_bar_close — at most one per bar

The table is here for you to compare against, not to memorize — every row solves the same problem: **the current bar's values keep changing, so any "compare to the past" logic has to wait for that bar to settle before judging**. Skip any one of these three habits and your backtest will look pretty while the live alerts start firing wildly.

If you actually want the alert to go out, don't use alertcondition

alertcondition() is the old API — Pine's original way of "raising an alert" — and it forces you to open a matching alert in the TradingView chart UI, pick a condition, and wire up the webhook. A webhook here is the channel that automatically pushes the alert payload to your own server; the message body itself is filled in inside that alert dialog.

Using it for divergence detection has two limits. The message can't carry dynamic values (like "how much higher is this RSI than the previous one"); and the trigger frequency isn't written in Pine — you have to pick it in the Create Alert dialog's Frequency dropdown. TradingView's Help Center page "Differences between alert frequencies" lists four options verbatim: Once only / Once per bar / Once per bar close / Once per minute or every time.

The newer approach is alert(): the message can be assembled as a runtime string inside Pine, and the frequency is specified with a Pine constant (for example alert.freq_once_per_bar_close). If you're going to wire this into automated ordering, reach for it directly:

pine
if bullishDiv and barstate.isconfirmed
            msg = "RSI bullish divergence | price=" + str.tostring(pl) +
                  " prev=" + str.tostring(prevPriceLow) +
                  " rsi=" + str.tostring(pl_rsi, "#.##")
            alert(msg, alert.freq_once_per_bar_close)
        

`alert.freq_once_per_bar_close` means "at most one alert per bar, and only at the moment the bar closes." That's the most conservative setting divergence signals can run under — before you connect this to an execution layer (the [TradingView Webhook Complete Tutorial](/blog/en/tradingview-webhook-tutorial) covers the setup), run it under this setting for a couple of days first and watch what comes through.

The honest section: divergence isn't a guaranteed reversal, and scripted detection adds another lag

Statistically, divergence is a pattern of momentum exhaustion, not a promise that price will reverse. Continuous divergences can persist for a long time — RSI keeps diverging while price keeps walking its direction — and depending on the book you read, that's called either "hidden divergence" or "failed divergence."

Scripted detection of "a confirmed divergence pivot" typically arrives 3 to 8 bars later than what a chart-watcher would call, and divergence as a signal is already lagging by nature. These two lags stack:

  • From the eye seeing the pivot to the script confirming it: right bars (5 is common).
  • From Pine firing the alert to your execution endpoint receiving the webhook: another few seconds to a dozen.
  • From the execution endpoint sending a market order to the fill landing: one more round of slippage.

By the time the signal reaches you, price might have started to reverse — or might just keep going. Any automated divergence strategy needs a second confirmation condition (moving-average cross, volume expansion) to filter with; without that step, divergence turns into "your loudest source of noise" rather than a signal.

This post won't hand you backtest numbers or parameter recommendations — we haven't run the sweep across pairs and timeframes, and any number written here would just be a guess. Take the minimum viable implementation above into TradingView's Strategy Tester yourself, then compare "with the divergence filter" against "without it" on the same instrument to see how much long-term expected value actually shifts. If it shifts, keep going. If it doesn't, retreat to plain RSI/MACD crosses — coarser signals, but they hold up more consistently.

Once the divergence alert fires, do you enter?

A single divergence signal on its own isn't enough to decide on, which is the first question of the tree. Every branch below can be dropped without breaking the flow, but skipping any one degrades win rate.

1
Is the signal source post-close (barstate.isconfirmed)?
No →It's a mid-bar intermediate value — wait for the bar to close before looking again. Intermediate values can vanish a second later
Yes →Move to the next question
2
Does the higher timeframe agree (e.g., daily RSI above 50)?
No →Counter-trend divergences have a very high false-signal rate — usually skip
Yes →Move to the next question
3
Is there a second confirmation (volume, moving average, lower-timeframe reversal)?
No →Entering on a single divergence usually breaks even or negative over the long run — treat it as an observation, not an entry
Yes →Enter per your strategy, and use the divergence pivot as an initial stop reference

FAQ

Parameters and timeframes

What should I set left and right to?
The common range is 3 to 10. Smaller values give more signals with more noise and shorter lag; larger values are cleaner but miss fast reversals. Daily bars often use 5, hourly and below often use 3. There isn't a correct answer — compare two or three combinations in the Strategy Tester on the same instrument.
Can I detect divergence on the 1-minute timeframe?
Technically yes, practically the signals will drown in noise. 5-minute RSI can produce 20 divergences a day; 1-minute is worse. If you have to use it, pair it with a higher-timeframe RSI filter and only act when the larger cycle agrees.

Signals and execution

MACD divergence or RSI divergence — pick one?
Either works, mechanically they're the same — swap ta.pivotlow(rsi, left, right) for a pivot on the MACD line from ta.macd(). MACD tends to fire slightly earlier than RSI but also has more false signals. Some traders only enter when both diverge simultaneously.
Once the alert goes out, what should the execution side worry about?
Two things. TradingView's webhook has latency (a few seconds to a dozen), and divergence itself is already a lagging signal, so the actual fill price will not be the pivot you see on the chart. Signals on the same bar may also be deduped by the execution layer — that's a good thing, and stacking it with alert.freq_once_per_bar_close gives you double insurance.
Would the ta.valuewhen version be better?
Shorter to write, but the logic bends: ta.valuewhen(not na(pl), pl, 1) means "the value of pl the previous time pl was not na." Getting "the previous" instead of "the current" makes off-by-one bugs hard to trace. During debugging, the var version is easier to follow.

Get started

Ready to ship what you just learned?

Wire your RSI/MACD divergence alerts into automated orders — with your own API keys, a dry-run stage to observe for two days before going live, risk limits you set yourself, and every incoming signal auditable one by one in the dashboard's signals page.

Start free