Skip to content

Multi-Timeframe Indicators

Build indicators that read data from multiple timeframes for comprehensive market analysis.

💡
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.

It helps to have read about bar 0 being incomplete first, on the price-data page — this page is that same problem one level up, where it is both harder to see and more damaging.

Why Multi-Timeframe Analysis?

Professional traders rarely rely on a single timeframe. A common approach is to use a higher timeframe to identify the trend and a lower timeframe to time entries. Multi-timeframe (MTF) indicators automate this by displaying data from one timeframe on a chart of a different timeframe.

For example, you might want to see the Daily RSI value on your H1 chart, or display the H4 moving average on your M15 chart.

The Challenge of MTF Indicators

MTF indicators are harder to build than single-timeframe indicators because:

  • Higher timeframe bars do not align 1:1 with lower timeframe bars
  • One H4 bar covers 16 M15 bars — you must map the value across all 16
  • Data synchronization: the higher timeframe data might not be loaded yet
  • Bars form at different times — you need to match by timestamp, not index

Core Technique: iBarShift

The key function for MTF indicators is finding which bar on the higher timeframe corresponds to each bar on the current timeframe:

// For each bar on current chart, find the corresponding
// bar index on the higher timeframe
int htfBarIndex = iBarShift(_Symbol, higherTF, time[i]);

// Then get the indicator value for that bar
double htfValue = iClose(_Symbol, higherTF, htfBarIndex);

Complete MTF RSI Example

#property indicator_separate_window
#property indicator_buffers 1
#property indicator_plots   1
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrDodgerBlue
#property indicator_width1  2
#property indicator_minimum 0
#property indicator_maximum 100
#property indicator_level1  30
#property indicator_level2  70

input ENUM_TIMEFRAMES HTF_Period = PERIOD_H4;  // Higher Timeframe
input int RSI_Period = 14;                      // RSI Period

int rsiHandle;
double rsiBuffer[];

int OnInit()
{
    // Create RSI handle on the HIGHER timeframe
    rsiHandle = iRSI(_Symbol, HTF_Period, RSI_Period, PRICE_CLOSE);
    if(rsiHandle == INVALID_HANDLE)
    {
        Print("Failed to create RSI handle for ", EnumToString(HTF_Period));
        return INIT_FAILED;
    }

    SetIndexBuffer(0, rsiBuffer, INDICATOR_DATA);
    IndicatorSetString(INDICATOR_SHORTNAME,
        "MTF RSI(" + IntegerToString(RSI_Period) + ", " +
        EnumToString(HTF_Period) + ")");

    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[])
{
    // Need enough bars on higher timeframe
    int htfBars = iBars(_Symbol, HTF_Period);
    if(htfBars < RSI_Period + 1)
        return 0;

    // Copy RSI values from higher timeframe
    double htfRSI[];
    ArraySetAsSeries(htfRSI, true);
    int copied = CopyBuffer(rsiHandle, 0, 0, htfBars, htfRSI);
    if(copied <= 0) return 0;

    // Get higher timeframe bar times
    datetime htfTime[];
    ArraySetAsSeries(htfTime, true);
    CopyTime(_Symbol, HTF_Period, 0, htfBars, htfTime);

    int start = (prev_calculated == 0) ? RSI_Period : prev_calculated - 1;

    for(int i = start; i < rates_total; i++)
    {
        // Find which HTF bar this current bar belongs to
        int htfIndex = -1;
        for(int j = 0; j < copied - 1; j++)
        {
            if(time[i] >= htfTime[j + 1] && time[i] < htfTime[j])
            {
                htfIndex = j + 1;  // use completed HTF bar
                break;
            }
        }

        if(htfIndex >= 0 && htfIndex < copied)
            rsiBuffer[i] = htfRSI[htfIndex];
        else
            rsiBuffer[i] = EMPTY_VALUE;
    }

    return rates_total;
}

void OnDeinit(const int reason)
{
    IndicatorRelease(rsiHandle);
}

Key Design Decisions

Use completed bars only: Always display the value from the last completed higher timeframe bar, not the currently forming one. The forming bar's value changes with every tick, which creates misleading signals on the lower timeframe.

Step vs smooth display: This indicator creates a "step" pattern — the value stays flat until a new HTF bar completes, then jumps to the new value. This is the correct representation. Interpolating between values would create fictional data.

⚠️
MTF indicators and backtesting

MTF indicators can produce misleading results in the Strategy Tester if it does not have enough higher timeframe history. Always verify that the HTF data is loaded before relying on backtest results.

Using iCustom for MTF

You can also create an MTF version of any existing custom indicator using iCustom():

// Run any custom indicator on a different timeframe
int customHandle = iCustom(_Symbol, PERIOD_H4,
    "MyIndicator",   // indicator filename (without .ex5)
    param1, param2   // input parameters
);
// Then use CopyBuffer to read its values

Read a Higher Timeframe Without Reading the Future, in Five Steps

“Trade the M15 chart but only in the direction of the H1 trend” is one of the most common ideas in trading, and one of the easiest to implement wrongly. The reason is specific and mechanical: when you are standing on an M15 bar, the H1 bar you are inside has not finished, so its close does not yet exist — and a great many multi-timeframe indicators use it anyway. In the next half hour you will build a higher timeframe from a lower one, find the exact index that is safe to read, and handle the case where the higher timeframe has not loaded at all. 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
Build a higher timeframe from a lower one

Go: the same folder.

Do: save this as aggregate.mq5 and run sh build.sh aggregate.

#include "mql5.h"

//+------------------------------------------------------------------+
//| A higher timeframe is not different data. It is the same data,    |
//| grouped. Building it yourself removes most of the mystery.        |
//+------------------------------------------------------------------+
struct Bar { datetime time; double open, high, low, close; };

// Group `factor` bars into one. Oldest-first in, oldest-first out.
int Aggregate(const std::vector<Bar> &src, std::vector<Bar> &dst, int factor)
{
    ArrayResize(dst, 0);
    for(int i = 0; i + factor <= ArraySize(src); i += factor)
    {
        Bar b;
        b.time  = src[i].time;            // the group's OPENING time
        b.open  = src[i].open;
        b.close = src[i + factor - 1].close;
        b.high  = src[i].high;
        b.low   = src[i].low;
        for(int k = 1; k < factor; k++)
        {
            b.high = MathMax(b.high, src[i + k].high);
            b.low  = MathMin(b.low,  src[i + k].low);
        }
        dst.push_back(b);
    }
    return ArraySize(dst);
}

void OnStart()
{
    std::vector<Bar> m1;
    double p = 100.0;
    for(int i = 0; i < 15; i++)
    {
        Bar b;
        b.time = 1700000000 + i * 60;
        b.open = p; b.close = p + ((i % 3) - 1) * 0.5;
        b.high = MathMax(b.open, b.close) + 0.3;
        b.low  = MathMin(b.open, b.close) - 0.3;
        p = b.close;
        m1.push_back(b);
    }

    Print("M1 bars:");
    for(int i = 0; i < ArraySize(m1); i++)
        Print("  ", i, "  t=", m1[i].time, "  o=", DoubleToString(m1[i].open, 2),
              " h=", DoubleToString(m1[i].high, 2), " l=", DoubleToString(m1[i].low, 2),
              " c=", DoubleToString(m1[i].close, 2));

    std::vector<Bar> m5;
    int n = Aggregate(m1, m5, 5);

    Print("");
    Print("aggregated into ", n, " M5 bars:");
    for(int i = 0; i < n; i++)
        Print("  ", i, "  t=", m5[i].time, "  o=", DoubleToString(m5[i].open, 2),
              " h=", DoubleToString(m5[i].high, 2), " l=", DoubleToString(m5[i].low, 2),
              " c=", DoubleToString(m5[i].close, 2));

    Print("");
    Print("open  = the FIRST bar's open       close = the LAST bar's close");
    Print("high  = the highest high of all    low   = the lowest low of all");
    Print("time  = the time the group OPENED, not when it closed");
}

int main() { OnStart(); return 0; }

You should see: fifteen one-minute bars becoming three five-minute bars:

M1 bars:
  0  t=1700000000  o=100.00 h=100.30 l=99.20 c=99.50
  1  t=1700000060  o=99.50 h=99.80 l=99.20 c=99.50
  2  t=1700000120  o=99.50 h=100.30 l=99.20 c=100.00
  3  t=1700000180  o=100.00 h=100.30 l=99.20 c=99.50
  4  t=1700000240  o=99.50 h=99.80 l=99.20 c=99.50
  5  t=1700000300  o=99.50 h=100.30 l=99.20 c=100.00
  6  t=1700000360  o=100.00 h=100.30 l=99.20 c=99.50
  7  t=1700000420  o=99.50 h=99.80 l=99.20 c=99.50
  8  t=1700000480  o=99.50 h=100.30 l=99.20 c=100.00
  9  t=1700000540  o=100.00 h=100.30 l=99.20 c=99.50
  10  t=1700000600  o=99.50 h=99.80 l=99.20 c=99.50
  11  t=1700000660  o=99.50 h=100.30 l=99.20 c=100.00
  12  t=1700000720  o=100.00 h=100.30 l=99.20 c=99.50
  13  t=1700000780  o=99.50 h=99.80 l=99.20 c=99.50
  14  t=1700000840  o=99.50 h=100.30 l=99.20 c=100.00

aggregated into 3 M5 bars:
  0  t=1700000000  o=100.00 h=100.30 l=99.20 c=99.50
  1  t=1700000300  o=99.50 h=100.30 l=99.20 c=99.50
  2  t=1700000600  o=99.50 h=100.30 l=99.20 c=100.00

open  = the FIRST bar's open       close = the LAST bar's close
high  = the highest high of all    low   = the lowest low of all
time  = the time the group OPENED, not when it closed

A higher timeframe contains no information the lower one lacks — it is the same ticks, grouped. The four rules at the bottom of the output are the whole of it: first open, last close, highest high, lowest low.

The timestamp is the detail that catches people. A bar is stamped with the time it opened. So an H1 bar labelled 14:00 covers 14:00 to 15:00, and at 14:20 you are inside it — which is exactly the situation step 4 is about.

Doing this by hand once is worth it even though MQL5 gives you CopyRates(_Symbol, PERIOD_H1, ...) for free, because it makes the next step obvious rather than mysterious.

If not: if you get fewer M5 bars than expected, the source count is not a multiple of the factor — the loop deliberately discards a trailing partial group, since a group that is not complete is not a bar yet. That is the same principle as the whole page.

4
Find the index that is safe to read

Go: the same folder. This is the step that decides whether the indicator is honest.

Do: save this as lookahead.mq5 and run sh build.sh lookahead.

#include "mql5.h"

//+------------------------------------------------------------------+
//| Reading a higher timeframe is where lookahead sneaks in.          |
//+------------------------------------------------------------------+
void OnStart()
{
    // Three H1 bars. Each covers 60 minutes.
    datetime h1_open[3]  = {1700000000, 1700003600, 1700007200};
    double   h1_close[3] = {100.5,      101.9,      99.4};

    // We are standing on the M1 bar 20 minutes into the SECOND H1 bar.
    datetime now = 1700003600 + 20 * 60;

    Print("current time            : ", now);
    Print("H1 bar 1 opened at      : ", h1_open[1], "  (we are inside it)");
    Print("H1 bar 1 will close at  : ", h1_open[1] + 3600);
    Print("minutes remaining       : ", (int)((h1_open[1] + 3600 - now) / 60));
    Print("");

    Print("WRONG: using the H1 bar we are standing inside");
    Print("   its 'close' is ", DoubleToString(h1_close[1], 2),
          " -- but that is the FINAL close,");
    Print("   which will not be known for another 40 minutes.");
    Print("");
    Print("RIGHT: using the last H1 bar that has actually closed");
    Print("   H1 bar 0, closed at ", h1_open[0] + 3600,
          ", close ", DoubleToString(h1_close[0], 2));
    Print("");

    // Which H1 index is safe, given the current time?
    for(int i = 0; i < 3; i++)
    {
        bool closed = (h1_open[i] + 3600) <= now;
        Print("   H1 index ", i, "  opened ", h1_open[i],
              "  closed? ", closed ? "yes -- safe to use" : "NO -- do not use");
    }

    Print("");
    Print("In MQL5, iClose(_Symbol, PERIOD_H1, 0) is the bar you are INSIDE.");
    Print("Its value changes for the rest of the hour. Use index 1.");
    Print("");
    Print("The tester will often hand you the finished H1 bar anyway, which is");
    Print("why a multi-timeframe strategy can score beautifully in a backtest");
    Print("and behave completely differently live. The strategy did not change;");
    Print("it stopped being told the answer.");
}

int main() { OnStart(); return 0; }

You should see: one H1 bar safe to use and two that are not:

current time            : 1700004800
H1 bar 1 opened at      : 1700003600  (we are inside it)
H1 bar 1 will close at  : 1700007200
minutes remaining       : 40

WRONG: using the H1 bar we are standing inside
   its 'close' is 101.90 -- but that is the FINAL close,
   which will not be known for another 40 minutes.

RIGHT: using the last H1 bar that has actually closed
   H1 bar 0, closed at 1700003600, close 100.50

   H1 index 0  opened 1700000000  closed? yes -- safe to use
   H1 index 1  opened 1700003600  closed? NO -- do not use
   H1 index 2  opened 1700007200  closed? NO -- do not use

In MQL5, iClose(_Symbol, PERIOD_H1, 0) is the bar you are INSIDE.
Its value changes for the rest of the hour. Use index 1.

The tester will often hand you the finished H1 bar anyway, which is
why a multi-timeframe strategy can score beautifully in a backtest
and behave completely differently live. The strategy did not change;
it stopped being told the answer.

iClose(_Symbol, PERIOD_H1, 0) is the bar you are standing inside. Its value keeps changing for the rest of the hour, so a rule built on it gives different answers at 14:05 and 14:55 — and in a backtest gives the final answer from the first minute onwards.

That last part is why multi-timeframe strategies so often look extraordinary in testing. Depending on the modelling mode, the tester may hand you a completed H1 bar while you are simulating an M15 bar inside it, which means the strategy is being told how the hour ends before it ends. Nothing in the code looks wrong; the code is simply receiving information that will not exist live.

The rule is short: on a higher timeframe, use index 1. If you want to be certain rather than trusting the index, compare the bar's open time plus its duration against the current time, as the loop above does.

If not: if every index reports safe, the comparison dropped the bar duration — a bar is closed only when its opening time plus one hour has passed, not when its opening time has.

5
Handle the higher timeframe not being there yet

Go: the same folder.

Do: save this as notready.mq5 and run sh build.sh notready.

#include "mql5.h"

//+------------------------------------------------------------------+
//| Higher-timeframe history is often not loaded yet. Plan for it.    |
//+------------------------------------------------------------------+

int tick_number = 0;

// Stands in for CopyClose on another timeframe: fails for the first few ticks,
// exactly as the real one does while the terminal downloads history.
int CopyClose_stub(std::vector<double> &dest, int count)
{
    tick_number++;
    if(tick_number <= 3)
        return -1;                       // not ready
    ArrayResize(dest, count);
    for(int i = 0; i < count; i++) dest[i] = 100.0 + i * 0.5;
    return count;
}

void OnTick()
{
    std::vector<double> h1;
    int got = CopyClose_stub(h1, 3);

    if(got <= 0)
    {
        Print("tick ", tick_number, "  H1 data not ready (returned ", got,
              ") -> do nothing, try again next tick");
        return;
    }
    Print("tick ", tick_number, "  got ", got, " H1 closes, newest ",
          DoubleToString(h1[0], 2), " -> evaluate signal");
}

void OnStart()
{
    for(int i = 0; i < 6; i++) OnTick();

    Print("");
    Print("The first three ticks had nothing to work with. An EA that ignored");
    Print("the return value would have compared a real price against an empty");
    Print("array -- and on a fresh chart that is exactly when it happens.");
    Print("");
    Print("Do NOT retry in a loop inside OnTick. The terminal fetches history");
    Print("in the background, so spinning blocks the very thread that would");
    Print("deliver it. Return, and let the next tick try again.");
}

int main() { OnStart(); return 0; }

You should see: three ticks with nothing to work with, then normal operation:

tick 1  H1 data not ready (returned -1) -> do nothing, try again next tick
tick 2  H1 data not ready (returned -1) -> do nothing, try again next tick
tick 3  H1 data not ready (returned -1) -> do nothing, try again next tick
tick 4  got 3 H1 closes, newest 100.00 -> evaluate signal
tick 5  got 3 H1 closes, newest 100.00 -> evaluate signal
tick 6  got 3 H1 closes, newest 100.00 -> evaluate signal

The first three ticks had nothing to work with. An EA that ignored
the return value would have compared a real price against an empty
array -- and on a fresh chart that is exactly when it happens.

Do NOT retry in a loop inside OnTick. The terminal fetches history
in the background, so spinning blocks the very thread that would
deliver it. Return, and let the next tick try again.

Requesting a timeframe you are not displaying means asking the terminal for history it may not have downloaded. It fetches it in the background and returns -1 until it arrives — typically for the first seconds after a chart opens, after a reconnection, or the first time you touch an unusual symbol or period.

The tempting fix is the wrong one. Retrying in a loop inside OnTick() blocks the very thread the terminal uses to deliver the data, so the loop can spin until it times out and the platform appears frozen. Return instead, and let the next tick try again — doing nothing is always a valid action for an indicator or an EA.

If you need history to be present before starting at all, request it in OnInit() and return INIT_FAILED when it is missing, which makes the terminal retry the whole initialisation rather than leaving you half-working.

If not: if every tick succeeds, the tick_number <= 3 guard was removed — it exists to reproduce a condition that is real but intermittent, and therefore almost never encountered while you are actively testing.

🎉
Check yourself before moving on

Without scrolling up: an EA trades M15 and filters by the H1 trend. In the strategy tester it is strongly profitable; live it takes different trades and loses. The developer insists the code is identical, and it is. What is the most likely cause, and how would you confirm it without running it live for another month? Answer: the H1 value is being read at index 0 — the bar currently forming. Step 4 showed that bar's close is not knowable until the hour ends, and that the tester may supply the completed value while simulating an M15 bar inside it, so the strategy is effectively told how the hour finishes before it does. Live it receives a value that changes throughout the hour, so it takes different trades. To confirm without waiting: change the H1 reference from index 0 to index 1 and re-run the same backtest. If the results collapse, the original was depending on information it did not have — and the new, worse number is the honest one. A second confirmation is to run the tester in “every tick” modelling and compare against “open prices only”; a large gap between modelling modes on a multi-timeframe strategy is itself a symptom of this.

Now do it without the page: extend aggregate.mq5 to build M15 bars from the same M1 data, then write a function that, given a current M1 time, returns the index of the most recent M15 bar that has genuinely closed. Test it at a time exactly on a boundary and one second before — the boundary case is where these functions are usually wrong, and where a strategy quietly gains one bar of hindsight.

Performance Considerations

  • MTF indicators are slower because they access data from multiple timeframes
  • Cache higher timeframe values — do not call CopyBuffer on every tick if the HTF bar has not changed
  • Use the new bar detection pattern to minimize recalculations
  • Limit the lookback — do not copy thousands of HTF bars if you only need the last 100