Non-Repainting Indicators: What Repainting Is and How to Test
Repainting explained for Pine Script and MQL: why historical signals differ from live ones, the five causes, barstate.isconfirmed, security() lookahead, and how to test any indicator.
Non-Repainting Indicators: What Repainting Is and How to Test
TLDR
An indicator repaints when the signals it shows on historical bars are not the signals it showed in real time on those same bars. A non-repainting indicator is one whose plotted history is a faithful record of what you would have seen live, bar by bar. Repainting has a handful of concrete causes (reading the forming bar, pulling higher-timeframe data without a confirmed offset, plotting with a negative offset, structures such as pivots and ZigZag that are only known in hindsight, and incremental-calculation bugs in MQL), each with a specific fix. This article explains all of them with Pine Script v6 and MQL5 code, and gives you a test procedure that works on indicators you did not write.
Repainting explained without hand-waving
A chart script runs in two different regimes. On historical bars it executes once per bar with the bar's final open, high, low and close already known. On the real-time bar it executes again on every tick, each time with a partial bar whose close is simply the latest price, and in Pine Script the values it calculated on the previous tick are rolled back before the next execution. When the bar finally closes, the last execution's values are committed and the bar becomes history.
Repainting is any situation in which the committed historical value differs from what the live trader saw at the time. Three flavors are worth separating, because people use the one word for all of them and then argue past each other:
- Real-time fluctuation. The current bar's RSI changes with every tick. That is not repainting; it is an unfinished bar. It becomes a problem only when a script treats an intrabar value as a signal and sends an alert or a trade on it.
- History rewriting. A signal that appeared on bar X live is gone after a refresh, or a signal appears on history that never appeared live. This is the damaging kind, and it is almost always a lookahead: the script, when run on history, had access to information it did not have in real time.
- Delayed confirmation drawn in the past. A pivot high that needs five more bars to confirm is drawn, when confirmed, on the bar where the high was. The drawing is honest about where the high is and dishonest about when you could have known. Backtests that enter on that bar are fiction.
The phrase "non repainting indicator" in marketing almost always means "does not rewrite history", and should also mean "does not draw signals you could not have acted on". Both are testable, and we will show how.
The five causes and their fixes
| Cause | Where it shows up | Pine Script fix | MQL4/MQL5 fix |
|---|---|---|---|
| Acting on the forming bar | Alerts and EAs that fire intrabar on a condition that later reverses | barstate.isconfirmed guard or alert.freq_once_per_bar_close; strategies keep calc_on_every_tick = false | Read bar 1, not bar 0; act on the first tick of a new bar |
| Higher-timeframe data without confirmed offset | request.security() / iCustom on a higher timeframe; MTF dashboards | Request close[1] with lookahead = barmerge.lookahead_on, or gate on the HTF bar being confirmed | Use shift 1 of the higher-timeframe series; never shift 0 |
| Negative plot offset | Arrows drawn N bars back from where they were computed | Plot at offset 0, or state the delay in the title and never backtest on the shifted bar | Write the buffer at index i, not i minus N |
| Hindsight structures | ZigZag, fractals, pivots, swing labels, "smart money" structure | Use the value only from the bar where it is confirmed (ta.pivothigh returns there); for ZigZag, treat the last leg as provisional | Treat ZigZag's last two points as mutable; signal only on the confirmed leg |
| Incremental recalculation bugs | MQL indicators that look right on load and drift live; Pine var misuse | Initialize with var carefully; avoid state that depends on tick count | Correct prev_calculated / IndicatorCounted() loops; recompute the last closed bar on every new bar |
Cause 1: acting on the forming bar
In Pine Script, barstate.isconfirmed is true on the last execution of a bar, the one whose values are committed. On historical bars it is always true, which means a condition like barstate.isconfirmed and ta.crossover(close, ema) produces the same signals on history and live. Without the guard, the crossover can be true at 10:17 on a 1 hour bar, false by 10:59, and the historical chart will correctly show no signal while your phone shows an alert from 10:17. Nothing was rewritten; the live decision was taken on data the bar later retracted.
The same concept in an expert advisor is reading bar 1 instead of bar 0, and doing so on the first tick of a new bar. We give a full EA skeleton in what is an expert advisor; for indicators the MQL5 example later in this article shows the buffer-side version. For webhook alerts, TradingView webhooks automation covers the frequency settings that encode this rule.
Cause 2: higher-timeframe data and request.security()
This is the most misunderstood source of repainting, so it is worth being precise. request.security(symbol, timeframe, expression) returns a higher-timeframe series on your chart's bars. The question is which HTF bar's value you get on a given chart bar, and the answer differs between history and real time.
- With the default lookahead = barmerge.lookahead_off, on historical bars a higher-timeframe value is only delivered once that HTF bar has closed. In real time, however, the function returns the current, still-developing HTF bar's value, which changes with every tick. The result is a series that lags on history and leads live, so a signal built on it will not match after a reload.
- With lookahead = barmerge.lookahead_on and no offset, historical chart bars receive the HTF bar's final value from the first chart bar inside that HTF period. That is a pure future leak: an intraday bar at 09:15 "knows" the daily close. Backtests built this way are the classic too-good-to-be-true equity curve.
- The documented idiom request.security(syminfo.tickerid, "D", close[1], lookahead = barmerge.lookahead_on) fixes both. The [1] asks for the previous HTF bar's close, which is already final when the current HTF period begins, and lookahead_on makes that already-known value available from the first chart bar of the period on history, exactly as it is live. History and real time now agree.
The script below plots the two side by side. Put it on a 15 minute chart with the input left at "D" and watch the red line move intrabar during the current session while the teal line steps once a day.
//@version=6 indicator("HTF close: repainting vs confirmed", overlay = true)htf = input.timeframe("D", "Higher timeframe")
// 1) Repaints. Lags on historical bars (value appears after the HTF bar closes) but in real time // returns the developing HTF close, so history and live never match. leaky = request.security(syminfo.tickerid, htf, close)
// 2) Does not repaint. Always the last COMPLETED higher-timeframe close, on history and live. // close[1] with lookahead_on is the documented idiom; drop the [1] and it becomes a future leak. confirmed = request.security(syminfo.tickerid, htf, close[1], lookahead = barmerge.lookahead_on)
plot(leaky, "Leaky HTF close", color.new(color.red, 0)) plot(confirmed, "Confirmed HTF close", color.new(color.teal, 0), 2)
// Signal only on confirmed chart bars, against the confirmed HTF level. bull = barstate.isconfirmed and ta.crossover(close, confirmed) bear = barstate.isconfirmed and ta.crossunder(close, confirmed) plotshape(bull, "Close above prior HTF close", shape.triangleup, location.belowbar, color.teal) plotshape(bear, "Close below prior HTF close", shape.triangledown, location.abovebar, color.red)
if barstate.isconfirmed and (bull or bear) alert(syminfo.ticker + (bull ? " above " : " below ") + "prior " + htf + " close " + str.tostring(confirmed, format.mintick), alert.freq_once_per_bar_close)
Two refinements people ask about. If you want the current HTF bar's developing high or low (for example today's running high on an intraday chart), that is legitimately a changing value and should be labelled as such; the repaint is in pretending it was known earlier, not in showing it. And lower-timeframe requests (request.security_lower_tf) return arrays of intrabar values that, by design, are only complete when the chart bar closes; use them under barstate.isconfirmed or accept that the live bar is partial.
Cause 3: negative plot offsets
plot(series, offset = -5) draws each value five bars to the left of the bar on which it was calculated. The classic use is a centered moving average, which is a smoothing tool and perfectly honest as long as nobody trades its turning points. The dishonest use is an arrow calculated after a five-bar confirmation and then drawn on the bar where the move started. On history it looks like the arrow called the move; live, the arrow appears five bars late and then jumps back. Any strategy or EA that reads the arrow's buffer at the drawn position is backtesting with the future. The fix is not clever: draw signals on the bar where they are known, and if you want to mark the origin as well, use a differently styled marker and a title that says "confirmed 5 bars later".
Cause 4: hindsight structures
ta.pivothigh(high, leftBars, rightBars) returns the pivot value only on the bar that is rightBars after the pivot, because that is the first bar on which the pivot is certain. Used correctly, with a signal on that bar, it does not repaint. The repaint arrives when the script draws the label at the pivot bar and the backtest reads the label. ZigZag is the same idea taken further: its last leg is provisional by definition and will extend or reverse as price moves, so any signal derived from "the last ZigZag turn" rewrites itself until the next turn confirms. Fractals, swing highs and lows, order blocks, break-of-structure labels and most "smart money concept" overlays inherit the same property. None of these are bad tools; all of them need the rule "a structure exists only from the bar on which it was confirmed", enforced in the code and in the backtest.
Cause 5: incremental-calculation bugs in MQL
MetaTrader indicators are recalculated incrementally. OnCalculate() receives rates_total (the number of bars) and prev_calculated (the number already processed on the previous call), and a well-behaved indicator recomputes only the bars from prev_calculated minus one onwards. Several things go wrong here in practice, and they are the usual explanation for an "MT4 indicator non repaint" claim that does not hold:
- Array direction confusion. In MQL4, price arrays and buffers are series: index 0 is the newest bar, and the bar "before" index i is i plus 1. In MQL5, the arrays passed to OnCalculate() are not series by default: index rates_total minus 1 is the forming bar and the previous bar is i minus 1. A loop written for one direction and compiled in the other reads the next bar as if it were the previous one, which on history is a one-bar future leak and live is simply unavailable. The result is an indicator that is perfect on load and wrong in real time.
- Stamping the forming bar. Writing a signal into the buffer at the current bar on every tick means the arrow appears and disappears intrabar; when the bar closes it may or may not stay. If the indicator never clears the buffer on later ticks, a stale arrow can remain on a bar whose final close did not qualify.
- Not revisiting the last closed bar. If the loop starts at prev_calculated rather than prev_calculated minus one, the bar that was forming on the previous call is never finalized with its closing values.
- Buffers that are cleared and redrawn. Some indicators wipe their buffers on every call and recompute everything from a lookback window, which can silently change past values when the window moves.
- ZigZag-based "non repaint" arrows. An arrow derived from the ZigZag's current leg will move with it, however the product page describes it.
Here is an MQL5 indicator that plots EMA-cross arrows and avoids these traps: it never writes a signal on the forming bar, it reprocesses the last closed bar on each new call, and it uses the non-series indexing the arrays actually have.
#property indicator_chart_window #property indicator_buffers 4 #property indicator_plots 2 #property indicator_type1 DRAW_ARROW #property indicator_color1 clrDodgerBlue #property indicator_type2 DRAW_ARROW #property indicator_color2 clrTomatoinput int InpFast = 20; input int InpSlow = 50;
double BuyBuf[], SellBuf[], FastBuf[], SlowBuf[]; int hFast = INVALID_HANDLE, hSlow = INVALID_HANDLE;
int OnInit() { SetIndexBuffer(0, BuyBuf, INDICATOR_DATA); SetIndexBuffer(1, SellBuf, INDICATOR_DATA); SetIndexBuffer(2, FastBuf, INDICATOR_CALCULATIONS); SetIndexBuffer(3, SlowBuf, INDICATOR_CALCULATIONS); PlotIndexSetInteger(0, PLOT_ARROW, 233); PlotIndexSetInteger(1, PLOT_ARROW, 234); PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE); PlotIndexSetDouble(1, PLOT_EMPTY_VALUE, EMPTY_VALUE); hFast = iMA(_Symbol, _Period, InpFast, 0, MODE_EMA, PRICE_CLOSE); hSlow = iMA(_Symbol, _Period, InpSlow, 0, MODE_EMA, PRICE_CLOSE); return (hFast == INVALID_HANDLE || hSlow == INVALID_HANDLE) ? INIT_FAILED : INIT_SUCCEEDED; }
int OnCalculate(const int rates_total, const int prev_calculated, const datetime &time[], const double &open[], const double &high[], const double &low[], const double &close[], const long &tick_volume[], const long &volume[], const int &spread[]) { if(rates_total < InpSlow + 2) return 0; // Arrays here are NOT series: index rates_total-1 is the forming bar, i-1 is the previous bar. if(CopyBuffer(hFast, 0, 0, rates_total, FastBuf) != rates_total) return 0; // retry next tick if(CopyBuffer(hSlow, 0, 0, rates_total, SlowBuf) != rates_total) return 0;
int start = (prev_calculated > 1) ? prev_calculated - 1 : InpSlow + 1; // revisit last closed bar for(int i = start; i < rates_total; i++) { BuyBuf[i] = EMPTY_VALUE; SellBuf[i] = EMPTY_VALUE; if(i == rates_total - 1) continue; // never stamp the forming bar bool up = FastBuf[i-1] <= SlowBuf[i-1] && FastBuf[i] > SlowBuf[i]; bool dn = FastBuf[i-1] >= SlowBuf[i-1] && FastBuf[i] < SlowBuf[i]; if(up) BuyBuf[i] = low[i]; if(dn) SellBuf[i] = high[i]; } return rates_total; }
When a new bar opens, prev_calculated equals the previous rates_total, so start points at the bar that was forming a moment ago; it is now closed and gets its final evaluation exactly once. An EA reading this indicator through iCustom at shift 1 sees the same arrows the chart shows, on history and live. Copying the full buffer on every tick is wasteful for very long histories; a production version copies only the bars it is about to process.
Worked example: one morning on a 15 minute chart
Suppose the higher timeframe is 1 hour and yesterday's last 1 hour close was 1.0850. The 1 hour bar from 09:00 to 10:00 is in progress; its running close is 1.0862 at 09:15, 1.0871 at 09:30, 1.0858 at 09:45, and it finally closes at 1.0866.
- The leaky series (request.security with close, lookahead off) shows 1.0862, 1.0871, 1.0858, 1.0866 live on the four 15 minute bars. After a reload, those same four historical bars show 1.0850 (the 08:00 to 09:00 close, since the 09:00 bar had not closed yet) for the first three and 1.0866 on the 09:45 bar where the 1 hour bar completes. Any signal computed against the live values is not reproducible.
- The confirmed series (close[1] with lookahead on) shows 1.0850 on all four bars live, and 1.0850 on all four bars after a reload. At 10:00 it steps to 1.0866 for the next four bars, live and on history alike.
If the 15 minute close at 09:30 was 1.0872, a "close above HTF close" rule fires live against the leaky series (1.0872 is above 1.0871) and does not fire on history (1.0872 is above 1.0850, but so were the earlier bars, and the crossover condition had already been consumed). Against the confirmed series the rule fires on the first 15 minute bar that closes above 1.0850 and nowhere else, in both regimes. That is the whole difference between a backtest you can trust and one you cannot.
How to test any indicator for repainting
You do not need the source to test, although it helps. The procedure below catches all five causes.
- Read the code if you have it. In Pine, search for request.security, lookahead, offset = -, ta.pivothigh, ta.pivotlow, ta.valuewhen, barstate, and var. Each hit is a question, not a verdict; check whether the result is used with a confirmed offset or guard. In MQL, look at the loop bounds in OnCalculate(), which index is treated as "previous", and whether bar 0 (MQL4) or rates_total minus 1 (MQL5) is written to.
- Screenshot the live chart at fixed times. Take a capture at the close of each bar for a session. Reload the chart the next day and compare bar by bar. Any arrow that moved, vanished or appeared is a repaint.
- Use Bar Replay in TradingView. Step forward one bar at a time and watch whether signals appear on the current bar or are back-filled onto earlier bars. Back-filling means hindsight.
- Log values at bar close. In Pine, a label or a table that prints the signal series under barstate.isconfirmed; in MQL, Print() or a file write at the first tick of each new bar. Compare the log with the historical plot later; they must agree exactly.
- Run the MetaTrader Strategy Tester in visual mode with an EA that reads the indicator at shift 1, in "Every tick" and "Open prices only" modes. If trade lists differ materially between the modes for a bar-close strategy, the indicator is using intrabar information it should not have.
- Try to break it on a higher timeframe. Put the indicator on a 1 minute chart with its higher-timeframe input set to daily. Repainting that is subtle on a 1 hour chart is obvious when the higher-timeframe bar contains hundreds of chart bars.
Step 2 is the one that settles arguments with vendors. It costs a day and no money.
What "non-repainting" does not guarantee
A non-repainting indicator is a correct record. It is not an edge. The honest arrow that appears five bars after a swing low is less exciting than the dishonest one drawn on the low, and that is the point: the honest backtest is the only one whose numbers mean anything. Expect a non-repainting version of a repainting signal to show later entries, fewer trades and a worse-looking equity curve than the version you were sold. If the strategy still has merit after that correction, you have something to automate; if it does not, you have saved the cost of finding out live.
FAQ
Is an indicator repainting just because the current bar's value changes?
No. The current bar is unfinished and its values are supposed to move. Repainting is when historical bars show something different from what was shown live, or when signals are drawn on bars where they could not have been known.
Does barstate.isconfirmed fix all repainting?
It fixes acting on the forming chart bar. It does not fix a higher-timeframe request without a confirmed offset, a negative plot offset, or a ZigZag-derived signal; those are separate causes with separate fixes, listed in the table above.
Is ZigZag a repainting indicator?
Its last leg is provisional by design and will change until the next confirmed turn. Earlier legs do not change. Treat it as a tool for reading structure, and derive signals only from confirmed legs.
Can a Heikin Ashi strategy repaint?
A strategy running on Heikin Ashi candles fills orders at synthetic prices unless told otherwise, which produces fills that could not have happened. In Pine Script v6 the strategy setting fill_orders_on_standard_ohlc (or the equivalent checkbox) makes the emulator fill at real prices. That is a backtest-accuracy issue rather than repainting in the strict sense, but it has the same effect: an equity curve you cannot reproduce.
How do I know if an MT4 indicator is really non repaint before buying it?
Ask for a demo, run it live for a week with timed screenshots, then reload and compare. Ask the seller specifically whether the arrows are derived from ZigZag or fractals and at which shift an EA should read the buffer. Vague answers are an answer.
Can an expert advisor repaint?
An EA does not draw history, so it cannot repaint in the visual sense, but it can suffer the same lookahead if it reads bar 0, reads a repainting indicator at shift 0, or uses higher-timeframe values at shift 0. The symptom is a backtest that no live run can match.
Where Viprasol fits
Viprasol builds indicators that pass the test procedure above and will say so in writing: Pine Script through the TradingView Pine Script development service and MetaTrader through MT5 indicator development. We also audit existing indicators and strategies for lookahead before they are automated, which is often the first step in an EA development or Pine to MQL5 conversion project. If you suspect a signal you rely on repaints, send us the script or the product page and we will tell you what to look for; our free tools page has calculators that help with the sizing side.
A non-repainting indicator describes the past correctly; it does not predict the future. Trading leveraged instruments can lose more than you deposit, and nothing here is investment advice.
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Ready to Automate Your Trading?
Discuss a custom Expert Advisor with defined strategy rules, risk controls and a testing plan.
Free consultation • No commitment • Response within 24 hours
Need a custom EA or trading bot built?
We build MT4/MT5 Expert Advisors around your strategy, broker and risk requirements. Each project has an agreed scope, testing plan and individual quote.