Pine Script Repainting: Why It Happens and How to Fix It

Pine Script Repainting: Why It Happens and How to Fix It

By HorizonAI Team · 13 min read · Intermediate

Your script printed a buy arrow at 14:32. You screenshotted it. By 14:45, when the 15-minute candle finally closed, the arrow was gone, and a different one had appeared two bars back. Nothing crashed, nothing errored, and the backtest still shows a clean equity curve.

That's repainting, and it's the single most common reason a TradingView script that looks flawless in hindsight falls apart the moment you trade it.

Short answer: Pine Script repaints when a script's output changes after the fact. It happens for two dominant reasons: the code evaluated on a bar that had not closed yet, or request.security() returned higher-timeframe data the chart could not legitimately have known at that point in time. Gate your signals with barstate.isconfirmed, and request higher-timeframe values as expression[1] paired with lookahead = barmerge.lookahead_on. Those two changes fix the large majority of real cases.

Repainting isn't one bug. It's four distinct behaviours that produce the same symptom:

  1. The realtime bar. Your condition is true at 14:32 and false at the close, because close, high and low are still moving. Fix: and barstate.isconfirmed.
  2. Future leak from request.security(). barmerge.lookahead_on with no historical offset hands past bars a value from the future. Fix: close[1] plus lookahead = barmerge.lookahead_on, together.
  3. Functions that confirm late. ta.pivothigh() needs right-hand bars before it can return anything. That's delay, not deceit, but it looks identical on a chart if you plot it wrong.
  4. Alerts fired mid-bar. An alert triggered on a tick that never survived to the close. Fix: alert.freq_once_per_bar_close.

Work through them in that order. The first two account for most of what people call repainting.

What repainting actually is

Pine executes your script once per bar, left to right. On every historical bar, the OHLC values are already fixed. There is exactly one execution and one outcome.

The realtime bar is different. TradingView re-runs your script on every incoming tick, and close on that bar means "the price right now", not "the closing price". Your variables are recalculated from scratch each tick, then rolled back and recalculated again on the next one. Only when the bar closes does the last of those executions become permanent.

So a script that says close > ema50 is asking a different question on the two bar types. On history it asks "did this bar close above the EMA". Live it asks "is price above the EMA at this instant". Those aren't the same condition, and the chart can't show you both. Reload the chart and every realtime bar you watched becomes a historical bar, recalculated once, cleanly. Your intrabar arrows disappear.

TradingView's own repainting documentation is blunt about the fact that this is not automatically a defect: "Not all repainting behavior is inherently useless or misleading." A support zone that widens as new highs print is doing its job. A volume profile that redistributes intrabar is doing its job.

The line worth drawing is this: anything that produces a signal or an alert must not repaint. A zone can move. An entry arrow cannot. If your script draws arrows, fires alerts, or feeds a strategy, every cause below applies to you.

Cause 1: your signal is evaluating on an unfinished bar

This is the one you're almost certainly hitting. Here's the shape of it:

//@version=6
indicator("RSI cross — repaints intrabar", overlay = true)

rsiLen = input.int(14, "RSI length", minval = 2)
oversold = input.int(30, "Oversold level")

r = ta.rsi(close, rsiLen)

// True the instant RSI ticks above 30, false again if it drops back before the close.
longSignal = ta.crossover(r, oversold)

plotshape(longSignal, "Long", shape.triangleup, location.belowbar, color.new(color.teal, 0), size = size.small)

On the 15-minute chart, RSI(14) is recomputed from a close that is still moving. A brief spike takes RSI from 29.4 to 30.2, ta.crossover() returns true, the arrow prints. Price fades, RSI settles at 29.1, and the arrow vanishes because the next tick's execution never produced it.

The fix is one boolean:

//@version=6
indicator("RSI cross — confirmed only", overlay = true)

rsiLen = input.int(14, "RSI length", minval = 2)
oversold = input.int(30, "Oversold level")

r = ta.rsi(close, rsiLen)

rawSignal = ta.crossover(r, oversold)

// barstate.isconfirmed is true only on the final execution of a bar, which is
// exactly the state every historical bar is permanently in.
longSignal = rawSignal and barstate.isconfirmed

plotshape(longSignal, "Long", shape.triangleup, location.belowbar, color.new(color.teal, 0), size = size.small)

barstate.isconfirmed is true on the last execution of any bar. On historical bars that's the only execution, so the gated condition behaves identically on both sides of the history/realtime boundary. That equivalence is the whole point. You aren't making the script "safer", you're making live behaviour match what the backtest already assumed.

The cost is honest and worth stating: you now act one bar late. On a 15-minute chart that's up to 15 minutes of delay. If your edge can't survive that, it was never an edge, it was intrabar noise.

The four bar states, and when each one is true

Print them on a chart once and you'll stop guessing:

//@version=6
indicator("Bar state audit", overlay = true)

var info = table.new(position.top_right, 2, 4, border_width = 1)

writeRow(int rowIdx, string rowName, bool flag) =>
    table.cell(info, 0, rowIdx, rowName, text_color = color.white)
    table.cell(info, 1, rowIdx, flag ? "true" : "false", text_color = flag ? color.lime : color.gray)

if barstate.islast
    writeRow(0, "ishistory", barstate.ishistory)
    writeRow(1, "isrealtime", barstate.isrealtime)
    writeRow(2, "isconfirmed", barstate.isconfirmed)
    writeRow(3, "islast", barstate.islast)
  • barstate.ishistory — the bar was already closed when the script loaded. Every bar except the current one, while the market is open.
  • barstate.isrealtime — the bar is updating live. Only ever the rightmost bar.
  • barstate.isconfirmed — this is the last execution for this bar. True once per bar, always.
  • barstate.islast — the rightmost bar on the chart, whether or not the market is open. Useful for drawing, useless for signals.

A common mistake is reaching for barstate.islast when you meant isconfirmed. islast is a position on the chart, not a state of completion.

What calc_on_every_tick does to strategies

Indicators always recalculate on every realtime tick. Strategies don't: by default a strategy executes only on the close of the realtime bar, which is why a plain strategy() script is much harder to make repaint than an indicator() one. Set calc_on_every_tick = true and you opt back into tick-by-tick evaluation, with orders that fire intrabar and won't reproduce on a reload. Turn it on only when the logic genuinely needs intrabar price, and even then gate the entry conditions.

Cause 2: request.security() is leaking the future

This is the dangerous one, because it doesn't look like a bug. It looks like the best strategy you have ever written.

request.security() pulls data from another timeframe, and its lookahead parameter decides what the past is allowed to see. The two settings behave very differently, and the difference is documented precisely in TradingView's other timeframes and data reference:

SettingOn historical barsOn the realtime bar
barmerge.lookahead_off (default)Returns the last confirmed higher-timeframe valueReturns the current, still-forming value
barmerge.lookahead_onReturns the value from the completed higher-timeframe bar, including bars that had not finished yet at that point in chart timeSame as lookahead_off

Read the two cells on the first row again. With the default lookahead_off, history sees only confirmed data while realtime sees a moving number. That's a mismatch, and it's why the default isn't automatically safe. With lookahead_on and no offset, history sees data from the future outright. That isn't a mismatch, it's fabrication, and TradingView won't let you publish it.

The version that fabricates:

//@version=6
indicator("HTF trend — leaks the future", overlay = true)

htfTf = input.timeframe("240", "Higher timeframe")

// On every 15m bar inside a 4H candle, this returns that 4H candle's FINAL close.
// The backtest knows how the 4H bar ended four hours before it ended.
htfClose = request.security(syminfo.tickerid, htfTf, close, lookahead = barmerge.lookahead_on)

plot(htfClose, "4H close", color = color.orange, linewidth = 2)

The version that is honest:

//@version=6
indicator("HTF trend — confirmed values only", overlay = true)

htfTf = input.timeframe("240", "Higher timeframe")
emaLen = input.int(50, "HTF EMA length", minval = 1)

// The [1] offset asks for the PREVIOUS higher-timeframe bar, which is closed and
// final. lookahead_on then delivers that confirmed value at the correct time on
// historical bars. Remove either half and the pattern breaks.
htfValue(simple string tf, simple int len) =>
    request.security(syminfo.tickerid, tf, ta.ema(close, len)[1], lookahead = barmerge.lookahead_on)

trend = htfValue(htfTf, emaLen)

plot(trend, "HTF EMA (confirmed)", color = color.orange, linewidth = 2)
bgcolor(close > trend ? color.new(color.teal, 92) : color.new(color.red, 92))

The offset and the lookahead setting are a matched pair. The documentation states it directly: they "are interdependent. Neither can be removed without compromising the integrity."

Why the pair works: [1] guarantees you're reading a bar that has already closed, so there's no future information available to leak. lookahead_on then makes that already-confirmed value appear on the first chart bar of the new higher-timeframe period, rather than being held back. Without lookahead_on you still get a non-repainting result, but it arrives one full higher-timeframe bar late, so your 4H trend filter on a 15m chart is running on data up to eight hours old.

Keep the helper function and reuse it. Every higher-timeframe read in the script should go through one place, so there's exactly one line to audit.

Cause 3: functions that confirm late

Some built-ins are structurally unable to answer until bars arrive that haven't happened yet. ta.pivothigh() and ta.pivotlow() are the clearest case: with rightBars = 5, a pivot can't be identified until five bars after the candle that formed it.

That isn't repainting. The value never changes once returned. What repaints is a plot that back-dates it:

//@version=6
indicator("Pivots, plotted honestly", overlay = true)

leftBars = input.int(5, "Left bars", minval = 1)
rightBars = input.int(5, "Right bars", minval = 1)

ph = ta.pivothigh(high, leftBars, rightBars)
pl = ta.pivotlow(low, leftBars, rightBars)

// The offset draws the marker back at the bar that formed the pivot. That is a
// display choice. Trading logic must still use the bar where the value ARRIVED.
plot(ph, "Pivot high", color = color.red, linewidth = 3, style = plot.style_circles, offset = -rightBars)
plot(pl, "Pivot low", color = color.teal, linewidth = 3, style = plot.style_circles, offset = -rightBars)

Draw the marker where the pivot formed if you like the look, but never let an entry condition read it as though it were known then. The same discipline applies to two more traps:

  • ta.valuewhen() and ta.barssince() are fine on confirmed data and misleading on the realtime bar, because "the last time condition X was true" can change mid-bar if X is itself an unconfirmed condition. Gate the source condition, not the lookup.
  • Requesting a lower timeframe with request.security() returns only the last completed lower-timeframe bar within the current chart bar, which updates continuously. If you need intrabar detail, request.security_lower_tf() is the correct tool and it returns an array, so you handle the sequencing explicitly instead of pretending a single value existed all along.

This is the same family of error as look-ahead bias in testing, which we cover from the backtest side in 7 deadly backtesting mistakes.

Cause 4: the alert fired on a candle that never closed that way

You get an alert. You open the chart. There's no signal. Both things are true, because the alert was correct at the moment it fired and the bar then went somewhere else.

Alerts only ever trigger on the realtime bar, so this is entirely a frequency problem. Pine gives you three settings on alert():

  • alert.freq_once_per_bar — fires on the first call during the realtime bar. This is the default, and it's the one burning you.
  • alert.freq_once_per_bar_close — fires only when the realtime bar closes.
  • alert.freq_all — fires on every call. Use for genuine tick-level monitoring, nothing else.
//@version=6
indicator("RSI alert — close only")

rsiLen = input.int(14, "RSI length", minval = 2)
oversold = input.int(30, "Oversold level")

r = ta.rsi(close, rsiLen)
longSignal = ta.crossover(r, oversold) and barstate.isconfirmed

plot(r, "RSI", color = color.new(color.blue, 0))
hline(oversold, "Oversold", color = color.gray, linestyle = hline.style_dashed)

if longSignal
    alert("RSI(" + str.tostring(rsiLen) + ") crossed above " + str.tostring(oversold) + " on " + syminfo.ticker + " " + timeframe.period, alert.freq_once_per_bar_close)

The frequency argument is what actually controls firing. barstate.isconfirmed keeps the plotted arrow honest. Setting both costs nothing and means the visual and the notification can never disagree.

Two related points. If you're still on alertcondition(), note that it takes a constant string message and works in indicators only, so dynamic prices can't be embedded. alert() works in indicators and strategies and builds its message at runtime, which is why TradingView's alerts documentation calls it easier and more flexible. And when you create the alert in the UI, the condition dropdown still has its own "Once Per Bar" / "Once Per Bar Close" setting for alertcondition() and order-fill alerts. Set it to match your code. We walk through the full alert wiring in the Supertrend automation guide.

Putting it together: one script, both versions

Here's a complete signal script with all three mistakes in it. RSI(14) oversold cross on a 15-minute chart, filtered by a 4H EMA(50) trend.

//@version=6
indicator("RSI + HTF trend — REPAINTING", overlay = true)

rsiLen = input.int(14, "RSI length", minval = 2)
oversold = input.int(30, "Oversold level")
htfTf = input.timeframe("240", "Trend timeframe")
trendLen = input.int(50, "HTF EMA length", minval = 1)

r = ta.rsi(close, rsiLen)

// Bug 1: lookahead_on with no offset. Every 15m bar inside a forming 4H candle
// already knows that candle's final EMA value.
htfEma = request.security(syminfo.tickerid, htfTf, ta.ema(close, trendLen), lookahead = barmerge.lookahead_on)

// Bug 2: evaluated on every tick of the open 15m bar, so the arrow comes and goes.
longSignal = ta.crossover(r, oversold) and close > htfEma

plotshape(longSignal, "Long", shape.triangleup, location.belowbar, color.new(color.teal, 0), size = size.small)
plot(htfEma, "4H EMA", color = color.orange)

// Bug 3: default frequency, so this fires mid-bar on a condition that may not survive.
if longSignal
    alert("Long signal on " + syminfo.ticker)

The corrected version, same logic, three changes:

//@version=6
indicator("RSI + HTF trend — confirmed", overlay = true)

rsiLen = input.int(14, "RSI length", minval = 2)
oversold = input.int(30, "Oversold level")
htfTf = input.timeframe("240", "Trend timeframe")
trendLen = input.int(50, "HTF EMA length", minval = 1)

// Fix 1: one audited helper for every higher-timeframe read.
htfEma(simple string tf, simple int len) =>
    request.security(syminfo.tickerid, tf, ta.ema(close, len)[1], lookahead = barmerge.lookahead_on)

r = ta.rsi(close, rsiLen)
trend = htfEma(htfTf, trendLen)

rawSignal = ta.crossover(r, oversold) and close > trend

// Fix 2: nothing counts until the 15m bar it happened on is closed.
longSignal = rawSignal and barstate.isconfirmed

plotshape(longSignal, "Long", shape.triangleup, location.belowbar, color.new(color.teal, 0), size = size.small)
plot(trend, "4H EMA (confirmed)", color = color.orange, linewidth = 2)
bgcolor(close > trend ? color.new(color.teal, 94) : color.new(color.red, 94))

// Fix 3: the notification and the arrow now agree by construction.
if longSignal
    alert("RSI(" + str.tostring(rsiLen) + ") long on " + syminfo.ticker + " " + timeframe.period, alert.freq_once_per_bar_close)

Line by line, what changed:

  1. ta.ema(close, trendLen) became ta.ema(close, len)[1] inside a helper. History and realtime now read the same confirmed 4H EMA.
  2. longSignal gained and barstate.isconfirmed. The arrow prints once, at the close of the 15-minute bar, and stays.
  3. alert() gained alert.freq_once_per_bar_close. The message arrives at the same moment the arrow does.

The corrected script will show fewer signals than the original, and its backtest will look worse. That's the point. The original's extra signals weren't opportunities, they were the future being read out loud.

How to prove your script doesn't repaint

Don't take your own word for it. Three tests, in ascending order of confidence.

1. The reload test. Note every signal on the last twenty bars, screenshot it, then reload the chart. Signals that move or disappear were realtime artefacts. This takes fifteen seconds and catches Cause 1 immediately.

2. The bar-replay test. Open Bar Replay, rewind a few hundred bars, and step forward one bar at a time. Bar Replay feeds bars as closed data, so a script that behaves differently here than it does live is doing something conditional on bar state. Watch specifically for signals that appear on the bar before the one you expected: that's a lookahead leak.

3. The session comparison. Leave the script running through a live session, log what it prints, then compare against the same chart the next morning after everything has become history. Any divergence is real. This is the only test that catches slow leaks in higher-timeframe logic, because it's the only one that actually crosses the history/realtime boundary in real time.

The PineCoders FAQ is the reference the TradingView community has maintained on this for years, and it's worth a read once you've run the three tests.

Common mistakes

Using barstate.islast to gate a signal.

✅ Use barstate.isconfirmed. islast is true on the rightmost bar even when the market is closed, and it tells you nothing about completion.

Assuming lookahead_off is automatically safe.

✅ It's the default, and it still gives history confirmed values while giving realtime a moving one. Pair [1] with lookahead_on when the value drives a signal.

Wrapping the entire script in if barstate.isconfirmed.

✅ Gate the signal, not the calculation. Indicator values like RSI and EMA should keep updating so you can see them. Only the boolean that triggers an arrow or an alert needs the gate.

Setting calc_on_every_tick = true because the strategy "felt slow".

✅ Leave it off unless the logic needs intrabar price. It's the fastest way to make a strategy stop reproducing on reload.

Chasing a repaint that is actually a delay. A pivot with rightBars = 5 confirming five bars late is correct behaviour.

✅ Decide whether you need the value earlier. If you do, use fewer right bars and accept more false pivots. There's no third option.

Judging the fix by whether the backtest improved.

✅ A correct non-repainting script almost always backtests worse than the repainting one. Compare live results to backtest results instead, which is the comparison that was broken in the first place.

Pro tips

  • Put every request.security() call behind one helper function. When you audit the script six months from now, there's one line to check rather than nine call sites.
  • Build with the gate on from bar one. Adding barstate.isconfirmed to a finished script means re-tuning every threshold, because the values it was tuned on were intrabar.
  • On a 4H filter, one bar of delay is nothing. On a 1-minute scalper, it's the whole trade. Match the confirmation cost to the timeframe before you commit to a design.
  • Run the two scripts side by side. Keep the repainting original as a separate saved indicator and put both on the same chart for a week. The divergence between them is the exact size of the illusion you were trading.
  • Treat the strategy tester's "Bar Magnifier" setting as a related but separate concern. It changes intrabar fill assumptions, not signal timing, so it won't fix a repaint.

Generating a non-repainting version without rewriting it yourself

If the script you need to fix is one you found rather than one you wrote, retrofitting confirmation logic by hand means understanding somebody else's variable names first. HorizonAI edits and debugs existing Pine Script, with real compiler validation on the output, so you can hand it the broken script and describe the behaviour you want instead.

Paste the script into the chat and ask for the specific fix:

Rewrite this Pine Script v6 indicator so it does not repaint. Gate every signal with barstate.isconfirmed, and change the request.security call so it uses close[1] with barmerge.lookahead_on instead of reading the forming 4H bar. Keep the RSI 14 / oversold 30 logic and the 4H EMA 50 trend filter exactly as they are.

Or start from scratch with the constraint built in:

Build a Pine Script v6 indicator: RSI 14 oversold cross below 30 on the chart timeframe, filtered by a 4H EMA 50 trend, confirmed-bar signals only, with an alert() call using alert.freq_once_per_bar_close.

What comes back is working, compile-checked code you can keep editing in the same conversation, and it converts to MQL5 or NinjaScript if you want the same logic on MetaTrader or NinjaTrader. You still run it yourself in TradingView. HorizonAI writes the code, it doesn't place the trades.

Try it free →

FAQs

Is repainting always bad?

No. Drawing objects that update as new price arrives, such as support zones or volume profiles, are doing exactly what they should. The rule is narrower than "never repaint": signals, arrows, alerts and strategy entries must not change after a bar closes. Everything else is a display decision.

Do repainting indicators work for backtesting?

They produce backtest results, but the results are meaningless. A script using barmerge.lookahead_on without an offset gives every historical bar information from the future, so the equity curve reflects hindsight rather than a tradeable edge. If your live results are far worse than your backtest and you can't explain the gap, check the lookahead setting first.

Why does my alert fire twice on the same bar?

Almost always the frequency setting. The default alert.freq_once_per_bar fires on the first call during the realtime bar, and if the condition becomes true again after the script rolls back a tick, you can get a second notification. Switch to alert.freq_once_per_bar_close and gate the condition with barstate.isconfirmed.

Does barstate.isconfirmed make my strategy slower to enter?

Yes, by exactly one bar, and that's the honest cost of the fix. Strategies are less exposed than indicators here, because a strategy() script executes on the close of realtime bars by default unless you set calc_on_every_tick = true.

Can I detect repainting without waiting for a live session?

Partly. The reload test and Bar Replay catch realtime-bar artefacts and most lookahead leaks within minutes. Slow leaks in higher-timeframe logic only show up when the script actually crosses from realtime into history, so a one-session comparison is still the final check.

Final thoughts

Repainting isn't a mysterious property of certain indicators. It's the predictable result of writing code that doesn't distinguish between a bar that has finished and one that hasn't, and the fix is two habits: gate signals with barstate.isconfirmed, and read higher timeframes through one helper that uses [1] with lookahead_on.

One concrete thing to do next: open every script you currently trade, search for request.security, and check whether any call has lookahead_on without an offset in the expression. That single pattern is responsible for more fake equity curves than every other cause on this page combined.

Related articles

Questions about Pine Script repainting? Join our Discord to discuss with other traders!