Skip to content

Build Your First Custom Indicator

Step-by-step guide to creating a working custom indicator with indicator buffers and chart output.

💡
Before you start

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 (vs indicator_separate_window for oscillators)
  • indicator_buffers 2 — We need 2 data arrays
  • indicator_plots 2 — We draw 2 visible elements
  • indicator_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 draw
  • INDICATOR_COLOR_INDEX — Contains color indices for multi-colored plots
  • INDICATOR_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.

💡
Professional quality matters

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

1
Compile with F7

Fix any errors shown in the output panel. Common mistakes: missing semicolons, undeclared variables, wrong buffer count.

2
Attach to a chart

In MT5, find your indicator in Navigator > Indicators > Custom, and drag it onto a chart. Adjust the period in the dialog.

3
Verify visually

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.

1
Save the header that supplies MetaTrader's built-ins

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.

2
Save the build script

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.

3
Write the calculation and run it

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.

4
Handle the awkward cases the terminal actually passes

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.

5
Apply the one test that decides whether it can be traded

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.

🎉
Check yourself before moving on

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 countindicator_buffers must match the total number of SetIndexBuffer() calls
  • Forgetting to return rates_totalOnCalculate must return how many bars were processed
  • Array out of bounds — Accessing high[i - j] when i - j is negative
  • Not handling prev_calculated — Recalculating all bars on every tick kills performance