A C++ compiler and a terminal. No MetaTrader, no broker, no account and no
money. Most Linux machines already have g++; on macOS run
xcode-select --install once; on Windows use the Windows Subsystem for Linux or MSYS2.
If you have done an earlier MQL5 page you already have the two setup files — reuse them and start at step 3.
You will write the CALCULATION, not the drawing. Wiring a buffer to a chart line needs MetaEditor and takes four declarations, which step 3 lists. The calculation is the part that decides whether the indicator is correct, and it is the part you can run here.
What We Will Build
In this tutorial, we will build a complete custom indicator from scratch: a Simple Price Channel that draws upper and lower bands around price based on the highest high and lowest low over a configurable period. This is a practical indicator that many traders use for breakout detection.
Indicator Structure
Every MQL5 indicator has three mandatory sections:
- Property declarations — Tell MT5 how to display the indicator (number of buffers, draw types, colors)
- OnInit() — Initialize buffers and settings
- OnCalculate() — Calculate values for each bar
Complete Source Code
//+------------------------------------------------------------------+
//| PriceChannel.mq5 |
//| Custom Price Channel Indicator |
//+------------------------------------------------------------------+
#property indicator_chart_window // draw on main chart
#property indicator_buffers 2 // we need 2 data buffers
#property indicator_plots 2 // we draw 2 lines
// Plot 0: Upper Channel
#property indicator_label1 "Upper Channel"
#property indicator_type1 DRAW_LINE
#property indicator_color1 clrDodgerBlue
#property indicator_width1 2
// Plot 1: Lower Channel
#property indicator_label2 "Lower Channel"
#property indicator_type2 DRAW_LINE
#property indicator_color2 clrOrangeRed
#property indicator_width2 2
// Input parameters (user-configurable)
input int ChannelPeriod = 20; // Channel Period
// Indicator buffers
double upperBuffer[];
double lowerBuffer[];
//+------------------------------------------------------------------+
int OnInit()
{
// Validate inputs
if(ChannelPeriod < 2)
{
Print("Error: Channel Period must be >= 2");
return INIT_PARAMETERS_INCORRECT;
}
// Map buffers to plots
SetIndexBuffer(0, upperBuffer, INDICATOR_DATA);
SetIndexBuffer(1, lowerBuffer, INDICATOR_DATA);
// Set indicator name in the Data Window
IndicatorSetString(INDICATOR_SHORTNAME,
"Price Channel (" + IntegerToString(ChannelPeriod) + ")");
return 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[])
{
// Not enough bars to calculate
if(rates_total < ChannelPeriod)
return 0;
// Determine starting point
int start;
if(prev_calculated == 0)
start = ChannelPeriod - 1; // first run: start after enough bars
else
start = prev_calculated - 1; // subsequent: only process new bars
// Calculate for each bar
for(int i = start; i < rates_total; i++)
{
double highestHigh = high[i];
double lowestLow = low[i];
// Find highest high and lowest low over the period
for(int j = 1; j < ChannelPeriod; j++)
{
if(high[i - j] > highestHigh)
highestHigh = high[i - j];
if(low[i - j] < lowestLow)
lowestLow = low[i - j];
}
upperBuffer[i] = highestHigh;
lowerBuffer[i] = lowestLow;
}
return rates_total;
}
//+------------------------------------------------------------------+
Step-by-Step Breakdown
#property Declarations
These tell MT5 how to render the indicator:
indicator_chart_window— Draw on the main price chart (vsindicator_separate_windowfor oscillators)indicator_buffers 2— We need 2 data arraysindicator_plots 2— We draw 2 visible elementsindicator_type1 DRAW_LINE— Plot 0 draws as a line
SetIndexBuffer
This function connects your array to a plot. The first parameter is the buffer index (0-based), the second is your array, and the third specifies the buffer type:
INDICATOR_DATA— Contains values to drawINDICATOR_COLOR_INDEX— Contains color indices for multi-colored plotsINDICATOR_CALCULATIONS— Internal calculation buffer (not drawn)
The Calculation Loop
The key optimization is using prev_calculated. On the first call, it is 0 and we calculate all bars. On subsequent calls, it tells us how many bars were already processed, so we only calculate new bars. This makes the indicator efficient even on charts with thousands of bars.
This tutorial shows the fundamentals. Production-quality indicators need additional features: multiple drawing styles, customizable colors, alert conditions, multi-timeframe support, and robust error handling. Building professional indicators requires deep MQL5 expertise — that is what we offer as a service.
Testing Your Indicator
Fix any errors shown in the output panel. Common mistakes: missing semicolons, undeclared variables, wrong buffer count.
In MT5, find your indicator in Navigator > Indicators > Custom, and drag it onto a chart. Adjust the period in the dialog.
The upper line should touch the highest candle highs, and the lower line should touch the lowest candle lows over the period. Test with different period values.
Write an Indicator's Calculation and Prove It Cannot Cheat, in Five Steps
A custom indicator in MQL5 is mostly one function, OnCalculate(), which the terminal
calls and hands three things: how many bars exist, how many you had already done, and the prices.
Everything else — colours, line styles, the settings dialog — is declaration. In the next
half hour you will write that function, run it, handle the awkward cases the terminal really passes,
and finish with the one test that separates an indicator you can trade from one that only looks
wonderful in hindsight. Every line of output below came from running these files.
Go: open a terminal in a folder you can write to — cd ~/Desktop on macOS or Linux, cd %USERPROFILE%\Desktop on Windows.
Do: save this as mql5.h. If you already have it from an earlier page, reuse that
copy and skip to step 3.
// mql5.h - a small shim that lets a useful SUBSET of MQL5 compile and run
// with an ordinary C++ compiler, so you can practise the language without
// MetaTrader. It is NOT MetaTrader and does not trade anything.
#pragma once
#include <cstdio>
#include <cmath>
#include <string>
#include <vector>
#include <iostream>
#include <sstream>
typedef std::string string;
typedef long long datetime;
#define input static
template <typename T> int ArraySize(const std::vector<T> &a) { return (int)a.size(); }
template <typename T> void ArrayResize(std::vector<T> &a, int n) { a.resize(n); }
inline void Print() { std::cout << std::endl; }
template <typename T, typename... Rest>
void Print(const T &first, const Rest &...rest) {
std::cout << first;
if constexpr (sizeof...(rest) > 0) Print(rest...);
else std::cout << std::endl;
}
inline string DoubleToString(double v, int digits = 8) {
char buf[64]; snprintf(buf, sizeof buf, "%.*f", digits, v); return string(buf);
}
inline string IntegerToString(long long v) { return std::to_string(v); }
inline double MathAbs(double v) { return std::fabs(v); }
inline double MathMax(double a, double b) { return a > b ? a : b; }
inline double MathMin(double a, double b) { return a < b ? a : b; }
inline int MathRound(double v) { return (int)std::lround(v); }
You should see: nothing — this is a header file. Confirm with ls mql5.h (Windows:
dir mql5.h).
If not: no compiler? On Ubuntu or Debian sudo apt install g++; on macOS
xcode-select --install; on Windows use the Windows Subsystem for Linux or MSYS2.
Go: the same folder.
Do: save this as build.sh. It compiles a .mq5 file and executes the
result. Make it executable with chmod +x build.sh, or invoke it as
sh build.sh.
#!/bin/sh
# build.sh NAME -> compiles NAME.mq5 and runs it.
# MQL5 is close enough to C++ that a C++ compiler can run the subset used here,
# with mql5.h supplying the handful of built-ins MetaTrader would provide.
# Uses whichever compiler you have.
set -e
NAME="$1"
if command -v g++ >/dev/null 2>&1 && g++ -x c++ -std=c++17 -o "$NAME" "$NAME.mq5" 2>/dev/null; then
:
elif command -v clang++ >/dev/null 2>&1; then
clang++ -x c++ -std=c++17 -o "$NAME" "$NAME.mq5"
else
echo "No C++ compiler found. Install g++ (Linux: apt install g++, macOS: xcode-select --install)." >&2
exit 1
fi
"./$NAME"
You should see: nothing — this is the toolchain. ls should show both
mql5.h and build.sh.
If not: Permission denied means the chmod was skipped; use
sh build.sh NAME, which needs no execute bit.
Go: the same folder.
Do: save this as sma_ind.mq5 and run sh build.sh sma_ind.
#include "mql5.h"
//+------------------------------------------------------------------+
//| A real custom indicator, reduced to the part that matters. |
//| In MetaEditor this is OnCalculate(); the rest is #property lines. |
//+------------------------------------------------------------------+
input int MaPeriod = 3;
// The indicator's output. In MetaTrader this is wired to a chart line
// with SetIndexBuffer(0, MaBuffer, INDICATOR_DATA).
std::vector<double> MaBuffer;
int OnCalculate(const int rates_total,
const int prev_calculated,
const std::vector<double> &close)
{
if(rates_total < MaPeriod)
return 0; // not enough history yet -- draw nothing
ArrayResize(MaBuffer, rates_total);
// Arrays here are OLDEST-first (ArraySetAsSeries false), so we count up.
// Start where a full window first exists, and never before prev_calculated.
int start = (prev_calculated > 0) ? prev_calculated - 1 : MaPeriod - 1;
for(int i = start; i < rates_total; i++)
{
double sum = 0.0;
for(int k = 0; k < MaPeriod; k++)
sum += close[i - k];
MaBuffer[i] = sum / MaPeriod;
}
return rates_total; // tell the terminal how far we got
}
void OnStart()
{
std::vector<double> close = {10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 14.0};
int done = OnCalculate(ArraySize(close), 0, close);
Print("rates_total : ", ArraySize(close));
Print("returned : ", done);
Print("");
Print("bar close MA(", MaPeriod, ")");
for(int i = 0; i < ArraySize(close); i++)
{
bool have = (i >= MaPeriod - 1);
Print(" ", i, " ", DoubleToString(close[i], 1),
" ", have ? DoubleToString(MaBuffer[i], 4) : "(empty)");
}
Print("");
Print("The first ", MaPeriod - 1, " bars are empty because a ", MaPeriod,
"-bar average does not");
Print("exist there. That gap is normal and every indicator has one.");
}
int main() { OnStart(); return 0; }
You should see: the first two bars empty and the rest filled:
rates_total : 7
returned : 7
bar close MA(3)
0 10.0 (empty)
1 11.0 (empty)
2 12.0 11.0000
3 11.0 11.3333
4 10.0 11.0000
5 12.0 11.0000
6 14.0 12.0000
The first 2 bars are empty because a 3-bar average does not
exist there. That gap is normal and every indicator has one.
Check bar 3 by hand before going on. It averages bars 1, 2 and 3 — 11.0, 12.0 and 11.0 — and (11 + 12 + 11) / 3 = 11.3333, which is what printed. Verifying one value on paper is worth more than reading the loop twice, and it is the only way to know the indexing is right rather than merely plausible.
In MetaEditor the same function is surrounded by declarations rather than a
main: #property indicator_chart_window,
#property indicator_buffers 1, #property indicator_plots 1, and a
SetIndexBuffer(0, MaBuffer, INDICATOR_DATA) call inside OnInit() that
connects your array to the line the terminal draws. Those lines decide how it looks. The
function above decides what it says.
Note the array direction. Inside OnCalculate the price arrays arrive oldest-first
unless you change it, which is why this loop counts upward — the opposite of the series-order
convention used elsewhere. Mixing the two up is the most common reason a new indicator draws
nonsense.
If not: if every bar prints (empty), MaPeriod is larger than the
number of closes. If the values look shifted by one, the inner loop is subtracting k
from the wrong index — it must average the MaPeriod bars ending at
i.
Go: the same folder.
Do: save this as edges.mq5 and run sh build.sh edges.
#include "mql5.h"
//+------------------------------------------------------------------+
//| The three inputs OnCalculate() really receives, and what to do. |
//+------------------------------------------------------------------+
std::vector<double> MaBuffer;
int OnCalculate(int rates_total, int prev_calculated,
const std::vector<double> &close, int period)
{
// 1. Not enough bars for even one value.
if(rates_total < period)
{
Print(" rates_total=", rates_total, " period=", period,
" -> too few bars, return 0 and draw nothing");
return 0;
}
// 2. History was reloaded: the terminal passes prev_calculated = 0.
if(prev_calculated == 0)
Print(" prev_calculated=0 -> full recalculation of ", rates_total, " bars");
else
Print(" prev_calculated=", prev_calculated, " -> update from bar ",
prev_calculated - 1);
ArrayResize(MaBuffer, rates_total);
int start = (prev_calculated > 0) ? prev_calculated - 1 : period - 1;
for(int i = start; i < rates_total; i++)
{
double sum = 0.0;
for(int k = 0; k < period; k++) sum += close[i - k];
MaBuffer[i] = sum / period;
}
return rates_total;
}
void OnStart()
{
std::vector<double> few = {10.0, 11.0};
std::vector<double> many = {10, 11, 12, 11, 10, 12, 14, 13, 12, 14};
Print("a brand-new chart with 2 bars and period 5:");
Print(" returned ", OnCalculate(ArraySize(few), 0, few, 5));
Print("");
Print("the same indicator once 10 bars exist:");
Print(" returned ", OnCalculate(ArraySize(many), 0, many, 5));
Print("");
Print("the next tick, nothing new to calculate:");
Print(" returned ", OnCalculate(ArraySize(many), ArraySize(many), many, 5));
Print("");
Print("the user switches timeframe, so history reloads:");
Print(" returned ", OnCalculate(ArraySize(many), 0, many, 5));
Print("");
Print("Returning 0 means 'I calculated nothing'. The terminal then passes");
Print("prev_calculated = 0 next time, so you get another chance. Returning");
Print("rates_total means 'all done up to here'.");
Print("");
Print("Returning rates_total when you actually failed is the bug: the");
Print("terminal believes you, never calls you back for those bars, and");
Print("the indicator has a permanent hole in it.");
}
int main() { OnStart(); return 0; }
You should see: four different calls, including two full recalculations:
a brand-new chart with 2 bars and period 5:
rates_total=2 period=5 -> too few bars, return 0 and draw nothing
returned 0
the same indicator once 10 bars exist:
prev_calculated=0 -> full recalculation of 10 bars
returned 10
the next tick, nothing new to calculate:
prev_calculated=10 -> update from bar 9
returned 10
the user switches timeframe, so history reloads:
prev_calculated=0 -> full recalculation of 10 bars
returned 10
Returning 0 means 'I calculated nothing'. The terminal then passes
prev_calculated = 0 next time, so you get another chance. Returning
rates_total means 'all done up to here'.
Returning rates_total when you actually failed is the bug: the
terminal believes you, never calls you back for those bars, and
the indicator has a permanent hole in it.
The terminal calls OnCalculate constantly and not always with more
data than last time. A chart with too few bars, a tick that added nothing, a timeframe switch that
reloads history — all of them arrive at the same function, and the last one resets
prev_calculated to zero without warning.
The return value is a promise, and the terminal believes it. Returning
rates_total means “every bar up to here is calculated”; the terminal will
not offer those bars again. Returning it when you actually failed — because data was missing,
or you returned early from a guard — leaves a permanent gap in the indicator that no amount
of new ticks will fill.
So: return 0 when you did nothing, and rates_total only when it is
true.
If not: if the two-bar case prints a calculation rather than the guard message, the comparison
is <= where it should be < — with rates_total
exactly equal to the period there is exactly one value to compute, which is legitimate.
Go: the same folder.
Do: save this as repaint.mq5 and run sh build.sh repaint.
#include "mql5.h"
//+------------------------------------------------------------------+
//| An indicator that uses bars to its RIGHT. It cannot be traded. |
//+------------------------------------------------------------------+
// A centred average: uses `half` bars either side of i.
double Centred(const std::vector<double> &c, int i, int half)
{
if(i - half < 0 || i + half >= ArraySize(c)) return 0.0;
double sum = 0.0;
for(int k = -half; k <= half; k++) sum += c[i + k];
return sum / (2 * half + 1);
}
// A trailing average: uses only bars at or before i.
double Trailing(const std::vector<double> &c, int i, int period)
{
if(i < period - 1) return 0.0;
double sum = 0.0;
for(int k = 0; k < period; k++) sum += c[i - k];
return sum / period;
}
void OnStart()
{
std::vector<double> close = {10, 11, 13, 16, 20, 17, 14, 12, 11, 10, 12, 15};
Print("bar close centred(2) trailing(5)");
for(int i = 0; i < ArraySize(close); i++)
Print(" ", (i < 10 ? " " : ""), i, " ", DoubleToString(close[i], 1),
" ", DoubleToString(Centred(close, i, 2), 3),
" ", DoubleToString(Trailing(close, i, 5), 3));
Print("");
Print("Now the test that matters. At the moment bar 6 CLOSES, which of");
Print("these two values can actually exist?");
Print("");
Print(" trailing(5) at bar 6 : ", DoubleToString(Trailing(close, 6, 5), 3),
" -- needs bars 2..6, all closed. YES");
Print(" centred(2) at bar 6 : ", DoubleToString(Centred(close, 6, 2), 3),
" -- needs bars 4..8. Bars 7 and 8");
Print(" DO NOT EXIST YET. No.");
Print("");
Print("The centred version will draw a beautiful line that turns exactly");
Print("at every top and bottom -- once the bars either side have arrived.");
Print("On the live right-hand edge it has nothing to draw, and each new");
Print("bar CHANGES the values it already drew. That is 'repainting'.");
Print("");
Print("The test for any indicator: does computing bar i require any bar");
Print("with an index greater than i? If yes, it cannot be traded, however");
Print("good the backtest looks.");
}
int main() { OnStart(); return 0; }
You should see: one column that could exist at bar 6 and one that could not:
bar close centred(2) trailing(5)
0 10.0 0.000 0.000
1 11.0 0.000 0.000
2 13.0 14.000 0.000
3 16.0 15.400 0.000
4 20.0 16.000 14.000
5 17.0 15.800 15.400
6 14.0 14.800 16.000
7 12.0 12.800 15.800
8 11.0 11.800 14.800
9 10.0 12.000 12.800
10 12.0 0.000 11.800
11 15.0 0.000 12.000
Now the test that matters. At the moment bar 6 CLOSES, which of
these two values can actually exist?
trailing(5) at bar 6 : 16.000 -- needs bars 2..6, all closed. YES
centred(2) at bar 6 : 14.800 -- needs bars 4..8. Bars 7 and 8
DO NOT EXIST YET. No.
The centred version will draw a beautiful line that turns exactly
at every top and bottom -- once the bars either side have arrived.
On the live right-hand edge it has nothing to draw, and each new
bar CHANGES the values it already drew. That is 'repainting'.
The test for any indicator: does computing bar i require any bar
with an index greater than i? If yes, it cannot be traded, however
good the backtest looks.
The centred average is smoother, turns closer to the actual tops and bottoms, and is completely untradeable — because computing its value at bar 6 requires bars 7 and 8, which have not happened. On a live chart it has nothing to draw at the right-hand edge, and every new bar silently changes values it already drew.
This is what “repainting” means, and it is not always dishonest. Plenty of published indicators do it accidentally, by smoothing symmetrically or by marking a swing high that can only be confirmed once later bars exist. The backtest looks extraordinary because the indicator was, in effect, told the answer.
The test fits in one sentence and applies to anything: does computing bar i require any bar with an index greater than i? If yes, no amount of tuning will make it tradeable. Apply it to every indicator you download, and to your own.
If not: if the centred column is filled at bars 10 and 11, the bounds check was removed —
it must return 0 when i + half is past the end, which is the whole point.
Without scrolling up: you download an indicator that marks swing highs and lows with arrows. On historical data the arrows sit exactly on every turning point. What would you check before trading it, and what do you expect to find? Answer: check whether marking a bar requires bars that come after it. A swing high is normally defined as a bar higher than some number of bars on both sides, which means the arrow at bar i cannot be drawn until i plus that number of bars have closed — exactly the structure step 5 measured with the centred average. So the expectation is that the arrows are real but late: on a live chart nothing appears at the right-hand edge, and arrows appear several bars after the turn they mark. The historical chart looks perfect because every bar on it has a future. The concrete way to check without reading the code is to run it in the strategy tester in visual mode and watch the right-hand edge as bars arrive — if arrows appear behind the current bar rather than on it, that is the confirmation. The indicator may still be useful for describing structure after the fact; it cannot be the trigger for a trade.
Now do it without the page: change sma_ind.mq5 to compute an exponential average instead of a simple
one, keeping the same OnCalculate shape. You will hit a real problem immediately: an
EMA needs the previous EMA value, so the prev_calculated optimisation must not
recompute from an empty buffer. Work out where the calculation has to start, and what happens on the
very first call. Solving that is the difference between an indicator that works and one that works
until the user switches timeframe.
Common Beginner Mistakes
- Wrong buffer count —
indicator_buffersmust match the total number ofSetIndexBuffer()calls - Forgetting to return rates_total —
OnCalculatemust return how many bars were processed - Array out of bounds — Accessing
high[i - j]wheni - jis negative - Not handling prev_calculated — Recalculating all bars on every tick kills performance