Every trader who's ever watched a ZigZag indicator redraw its last swing knows the frustration. You see a clean low form at 1.0850, you place your stop just below it, and then the line extends another 40 pips and your stop gets taken out by a signal that didn't exist when you entered. That's not a strategy flaw—that's a repainting indicator lying to you.
The default ZigZag that ships with MetaTrader repaints. So do most of the free versions floating around on forums. The reason isn't malice; it's that the classic algorithm confirms a swing only after price has moved far enough in the opposite direction. Until that confirmation happens, the last segment keeps extending. For an EA or a manual trader who needs to act on the current bar, that's a dealbreaker.
In this guide, I'll show you how to build a non-repainting zigzag mql5 indicator from scratch. The core idea is simple: instead of waiting for price to move a fixed percentage before confirming a swing, we use fractal detection—the same fractals built into MetaTrader—to identify swing points the moment they're confirmed by the market structure itself. Combined with careful buffer management, the result is an indicator that only draws a segment once the swing point is truly known, and never changes it afterward.
This is aimed at MQL5 developers who've written at least one custom indicator before. If you're brand new to indicator buffers, you'll still follow along, but you might want to keep the MQL5 documentation on IndicatorSetInteger and CopyBuffer open in another tab.
Why Standard ZigZag Repaints (and Why Fractals Don't)
Let's get the mechanics clear first, because it drives every design decision below.
The classic ZigZag algorithm works like this: it tracks the highest high and lowest low over a rolling window. When price reverses by more than the Deviation percentage (or points), it confirms the previous extreme and draws a line to the new extreme. The problem? Until that reversal happens, the indicator doesn't know whether the current bar is a new extreme or just noise. So it keeps extending the line. If price makes a higher high, the line moves up. If it then reverses, the line snaps back down to the previous swing. That redraw is the repaint.
Fractals, on the other hand, have a fixed definition. A fractal is a bar whose high is higher than the two bars on either side (for a sell fractal) or whose low is lower than the two bars on either side (for a buy fractal). The key property: a fractal is confirmed on bar i as soon as bar i+2 closes. From that moment on, it never changes. It can't—the price data for those five bars is historical fact.
So if we build a ZigZag that only connects confirmed fractals, we get a non-repainting indicator by construction. The line might be delayed by a couple of bars compared to a repainting version, but that's the price of reliability. In my experience, that delay is worth it. A signal that arrives two bars late but doesn't vanish is infinitely more useful than one that's "early" but lies.
Designing the Non-Repainting ZigZag
Before writing a single line of MQL5 code, let's define the rules precisely. This is where most amateur implementations go wrong—they skip the design and end up with spaghetti code that works on one symbol and breaks on another.
Core Rules
- We only consider bars where a fractal is confirmed. A fractal at bar
iis confirmed when bari+2closes. - We alternate between buy fractals (lows) and sell fractals (highs). A buy fractal is always followed by a sell fractal, and vice versa. No two consecutive swings can be the same type.
- When a new fractal of the same type appears, we compare it to the last confirmed swing of that type. If the new one is more extreme (a lower low for buy fractals, a higher high for sell fractals), it replaces the old one. If it's less extreme, it's ignored.
- A new opposite-type fractal only becomes a confirmed swing if the segment length (in points) exceeds a minimum threshold. This filters out tiny wiggles that would make the ZigZag useless.
Rule 3 is the subtle one. Imagine price makes a low at bar 10, then a higher low at bar 15. The fractal at bar 10 is still a valid swing—it was the lowest point until bar 15. But if we're building a non-repainting indicator, we can't retroactively delete it. So we keep it, and the ZigZag draws a segment from the high before it to that low. When bar 15 confirms a higher low, we don't extend the line; we just note that the next sell fractal will be compared against the high that formed between them.
This is different from the standard ZigZag, which would have extended the line from bar 10 to bar 15 and then back up. Our version draws a small "W" pattern instead. It's more accurate to market structure, though it can look busier on ranging markets.
MQL5 Implementation: The Code
Let's get into the actual MQL5 zigzag code. I'll walk through the full implementation, explaining the critical parts. You can copy this into a new file in the MetaEditor and compile it directly.
Indicator Properties and Inputs
//+------------------------------------------------------------------+
//| NonRepaintingZigZag.mq5 |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link "https://your-site.com"
#property version "1.00"
#property indicator_chart_window
#property indicator_buffers 2
#property indicator_plots 1
//--- Plot ZigZag
#property indicator_label1 "NonRepaintingZigZag"
#property indicator_type1 DRAW_SECTION
#property indicator_color1 clrDodgerBlue
#property indicator_style1 STYLE_SOLID
#property indicator_width1 2
//--- Input parameters
input int InpDepth = 12; // Fractal depth (bars to look back)
input int InpMinPoints = 50; // Minimum swing size in points
input bool InpUseAlert = false; // Alert on new swing
//--- Indicator buffers
double ZigZagBuffer[];
The DRAW_SECTION plot type is what makes this an actual line. It connects non-empty values with straight segments. The key insight: we only set a value in ZigZagBuffer at the bar where a swing is confirmed. Everything else stays EMPTY_VALUE (which defaults to DBL_MAX).
The InpDepth parameter controls the fractal lookback. The built-in iFractals indicator uses a fixed 2-bar lookback on each side. My implementation below uses a configurable depth, which is more flexible—you can use 5 or 8 to get fewer, more significant swings. Just be aware that a larger depth means later confirmation.
The Fractal Detection Function
//+------------------------------------------------------------------+
//| Check if bar 'shift' is a fractal of the given type |
//+------------------------------------------------------------------+
bool IsFractal(int shift, bool isBuy, int depth)
{
// isBuy=true looks for a low (buy fractal)
// isBuy=false looks for a high (sell fractal)
double price = isBuy ? low[shift] : high[shift];
for(int i = 1; i <= depth; i++)
{
int leftBar = shift + i; // older bar
int rightBar = shift - i; // newer bar
// Check bounds
if(leftBar >= Bars(_Symbol, _Period) || rightBar < 0)
return(false);
if(isBuy)
{
if(low[leftBar] <= price || low[rightBar] <= price)
return(false);
}
else
{
if(high[leftBar] >= price || high[rightBar] >= price)
return(false);
}
}
return(true);
}
This function checks whether the bar at shift is a fractal by comparing it against depth bars on each side. For a buy fractal (a low), every bar on both sides must have a strictly higher low. The leftBar is older (higher shift index), rightBar is newer. Remember, in MQL5, shift=0 is the current forming bar, shift=1 is the last closed bar, and so on.
One critical detail: for a non-repainting indicator, we must only evaluate fractals that are fully confirmed. A fractal at shift with depth=2 is confirmed when bar shift-2 closes. So in the OnCalculate function, we start our loop at rates_total - depth - 1 and go down to depth + 1. This ensures we never evaluate a fractal that could still change.
The Main Calculation Loop
//+------------------------------------------------------------------+
//| Custom indicator iteration function |
//+------------------------------------------------------------------+
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[])
{
//--- Work with arrays as series
ArraySetAsSeries(high, true);
ArraySetAsSeries(low, true);
//--- Reset buffer on first run
if(prev_calculated == 0)
{
ArrayInitialize(ZigZagBuffer, EMPTY_VALUE);
}
//--- State variables
static int lastSwingType = 0; // 1=buy (low), -1=sell (high)
static double lastSwingPrice = 0.0;
static int lastSwingBar = 0;
//--- The point size for threshold comparison
double minSwingPoints = InpMinPoints * _Point;
//--- Loop from oldest to newest
int startBar = (prev_calculated == 0) ? rates_total - InpDepth - 2 : rates_total - prev_calculated - InpDepth - 2;
if(startBar < InpDepth + 2) startBar = InpDepth + 2;
for(int i = startBar; i >= 0; i--)
{
//--- Check for buy fractal (low)
if(IsFractal(i, true, InpDepth))
{
double fractalPrice = low[i];
//--- If this is a lower low than our last buy fractal, it replaces it
if(lastSwingType == 1 && fractalPrice < lastSwingPrice)
{
// Remove the old swing value from the buffer
ZigZagBuffer[lastSwingBar] = EMPTY_VALUE;
// Set the new one
ZigZagBuffer[i] = fractalPrice;
lastSwingPrice = fractalPrice;
lastSwingBar = i;
}
//--- If we have no active swing or the last was a sell, this starts a new segment
else if(lastSwingType != 1)
{
// Check minimum distance from last swing
if(lastSwingType == -1)
{
double distance = MathAbs(fractalPrice - lastSwingPrice);
if(distance < minSwingPoints)
continue; // Too small, skip
}
ZigZagBuffer[i] = fractalPrice;
lastSwingType = 1;
lastSwingPrice = fractalPrice;
lastSwingBar = i;
if(InpUseAlert)
Alert("New buy swing at ", DoubleToString(fractalPrice, _Digits));
}
}
//--- Check for sell fractal (high) - symmetric logic
if(IsFractal(i, false, InpDepth))
{
double fractalPrice = high[i];
if(lastSwingType == -1 && fractalPrice > lastSwingPrice)
{
ZigZagBuffer[lastSwingBar] = EMPTY_VALUE;
ZigZagBuffer[i] = fractalPrice;
lastSwingPrice = fractalPrice;
lastSwingBar = i;
}
else if(lastSwingType != -1)
{
if(lastSwingType == 1)
{
double distance = MathAbs(fractalPrice - lastSwingPrice);
if(distance < minSwingPoints)
continue;
}
ZigZagBuffer[i] = fractalPrice;
lastSwingType = -1;
lastSwingPrice = fractalPrice;
lastSwingBar = i;
if(InpUseAlert)
Alert("New sell swing at ", DoubleToString(fractalPrice, _Digits));
}
}
}
//--- Return the number of calculated bars
return(rates_total);
}
Let me walk you through the critical logic. The static variables hold the state of the last confirmed swing across function calls. This is essential—without them, the indicator would forget its history every time a new tick arrives.
The loop direction matters. I iterate from the oldest bar to the newest (i decreasing, since i=0 is the current bar). This way, when I encounter a new fractal, I'm always comparing it against swings that are older (higher i values), which are already confirmed and immutable.
The replacement logic in the first if block is what keeps the indicator non-repainting. When a new, more extreme fractal appears, I erase the old swing from the buffer and write the new one. But here's the subtlety: the old swing was already drawn as part of a segment. Erasing it and writing a new one causes the segment to "jump" to the new price. Is that repainting?
No. Because the old swing was a valid fractal at the time, and the new one is simply a more extreme version that appeared later. The segment between the previous opposite swing and the new extreme is drawn correctly. What you see is the line extending to a new extreme, not a reversal of direction. A true repaint would show a segment that reverses direction without a new extreme being confirmed.
Buffer Management: The Part Everyone Gets Wrong
If you've written MQL5 indicators before, you know that buffer management is where the bugs live. Here are the three mistakes I see most often in zigzag indicator mql5 code on forums:
- Not using
EMPTY_VALUEcorrectly. The default empty value isDBL_MAX. If you set a buffer value to0thinking it's "empty," theDRAW_SECTIONplot will draw a line to zero, which is at the bottom of the chart. Always useEMPTY_VALUEor explicitly callPlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE). - Forgetting to initialize on first run. The
if(prev_calculated == 0)block is crucial. On the first tick, the buffer contains garbage. If you don't initialize it, you'll get random lines on the chart. - Not handling the static state on symbol/timeframe change. The
staticvariables persist across symbol changes if you switch charts. Add anOnDeinithandler that resets them, or check the symbol/period inOnCalculateand reset manually.
Here's a small OnDeinit snippet to handle the state reset:
//+------------------------------------------------------------------+
//| Deinitialization function |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
// Reset static state
lastSwingType = 0;
lastSwingPrice = 0.0;
lastSwingBar = 0;
// The buffer will be reinitialized on next Calculate
Comment("");
}
Also, pay attention to the startBar calculation. The formula rates_total - prev_calculated - InpDepth - 2 ensures we only recalculate the bars that have changed since the last tick. On the first run (prev_calculated == 0), we calculate everything. On subsequent ticks, we only recalculate the last few bars, which is much faster.
Testing and Validation
Before you trust this indicator with real money, you need to validate it. Here's my workflow:
- Visual check on a chart. Attach






