TICKERALL!
← All posts
August 23, 20266 min read

Backtesting a MetaTrader 5 Strategy with Historical Candles in Python

Pull historical MetaTrader 5 OHLC candles from your broker account and backtest a moving-average crossover strategy in pure Python — fetch, signal, simulate.

MetaTrader 5BacktestingPythonMarket Data

Every strategy starts as a hunch. "If the fast average crosses above the slow one, momentum's turning — buy." The only honest way to find out whether the hunch is worth risking money on is to run it against history and count the P&L.

That means one thing before anything else: clean historical candles, and a lot of them. The traditional way to get them is to babysit a MetaTrader 5 terminal — install it, keep it logged in, script it, and hope it stays up while your research job runs. For research that's backwards. You want to pull a few thousand bars into a Python process, iterate on the idea, and never think about whether a desktop app is still running.

This post does exactly that: fetch OHLC candles from a connected account, compute a fast/slow SMA crossover signal, and simulate the P&L — in plain Python, no heavy dependencies. We'll be honest about what the toy simulation ignores, too, because a backtest that lies to you is worse than no backtest.

Candles OHLC history SMA crossover fast vs slow P&L sim count returns
Three stages: fetch candles, turn them into signals, simulate the returns those signals would have produced.

Fetching candles

Connect the same broker login you'd use in MetaTrader, then ask for bars. The candle call takes exactly one of three bounds — count, hours, or a from_/to date range — plus a timeframe:

from tickerall import Tickerall

client = Tickerall(api_key="cf_live_...")

session = client.sessions.start(
    broker="mt5", server="Exness-MT5Trial7", account=12345678, password="...",
)

# The 500 most-recent 15-minute bars, oldest-first.
bars = client.candles.get(
    session.account_id, symbol="BTCUSDm", count=500, timeframe="M15",
)

for c in bars[:3]:
    print(c.timestamp, c.open, c.high, c.low, c.close, c.volume)

Each candle c carries c.timestamp, c.open, c.high, c.low, c.close, and c.volume. Bars come back oldest-first, which is exactly the order you want for a walk-forward simulation — index 0 is the past, the last index is the most recent close.

If you'd rather bound by wall-clock time than bar count, use hours. This grabs roughly the last trading week of hourly bars:

bars = client.candles.get(
    session.account_id, symbol="BTCUSDm", hours=168, timeframe="H1",
)

Reaching further back with coarser timeframes

A crossover strategy needs enough history to see multiple regimes — trends, chop, reversals — or your backtest just memorizes one market mood. The lever for reach is the timeframe: coarser bars cover far more calendar time for the same request. A year of daily bars is a light pull; a year of M1 bars is a very different animal.

# ~1 year of daily bars — deep look-back, coarse timeframe.
bars = client.candles.get(
    session.account_id, symbol="BTCUSDm", hours=8760, timeframe="D1",
)

Rough guide to how far a single, sane request reaches:

Timeframe Good for Reaches back (ballpark)
M1 / M5 Execution, intraday microstructure Days to weeks
M15 / M30 Intraday swing signals Weeks to months
H1 / H4 Multi-day trends Months to a couple of years
D1 / W1 Regime studies, long backtests Years

Deep look-backs are served transparently — a large history request may be routed over a background history connection so it never interferes with your live tick stream. You just get the bars.

Match the timeframe to the strategy, not the other way around. If your idea trades on 15-minute momentum, backtesting it on daily bars will flatter it with a smoothness the real thing never had. Test on the bars you'd actually trade.

Computing the crossover signal

Two simple moving averages, fast and slow. When the fast one is above the slow one we consider the market "long"; when it's below, "flat." Pure Python, no libraries:

def sma(values, period):
    """Trailing simple moving average; None until enough bars exist."""
    out = []
    for i in range(len(values)):
        if i + 1 < period:
            out.append(None)
        else:
            window = values[i + 1 - period : i + 1]
            out.append(sum(window) / period)
    return out

closes = [c.close for c in bars]
fast = sma(closes, 20)
slow = sma(closes, 50)

# +1 = want to be long this bar, 0 = want to be flat.
position = [
    1 if (f is not None and s is not None and f > s) else 0
    for f, s in zip(fast, slow)
]

If you already lean on pandas, the same thing is a one-liner — df["close"].rolling(20).mean() for each average, then a boolean compare — but the loop above keeps the dependency list at zero and makes every step visible.

Simulating the P&L

Now walk the bars forward and add up what the signal would have earned. The key discipline is no look-ahead: the signal computed from a bar's close can only be acted on from the next bar onward. We hold the previous bar's position into the current bar's return:

equity = 0.0        # cumulative return, in price units per unit held
held = 0            # position carried into the current bar
trades = 0
wins = 0

for i in range(1, len(bars)):
    ret = closes[i] - closes[i - 1]
    equity += held * ret

    signal = position[i - 1]        # decided on the PRIOR close
    if signal != held:              # a crossover flipped us
        trades += 1
        held = signal

    if held == 1 and ret > 0:
        wins += 1

print(f"bars:      {len(bars)}")
print(f"net P&L:   {equity:.2f} price units")
print(f"trades:    {trades}")
print(f"win-bars:  {wins}")

Because bars are oldest-first, the loop is a straight walk from past to present. Swap the raw price-unit accumulation for percentage returns ((closes[i] / closes[i-1]) - 1) if you want something comparable across symbols of different notional size.

What this backtest is NOT telling you

The number that prints is optimistic, and it's important to know exactly why:

This toy ignores costs, and costs are where strategies die. It models no spread (you don't trade at the mid — you buy the ask and sell the bid), no slippage (fast markets fill you worse than the printed close), and no commission or swap. A crossover that flips often can look profitable on frictionless bars and bleed to death once you subtract a realistic spread from every entry and exit. Before you trust any result, subtract an honest per-trade cost — widen it until the edge either survives or doesn't.

Two more traps worth naming. Look-ahead bias: we guarded against it by acting on the prior close, but it creeps back in the moment you peek at a bar's high or low to decide that same bar's action — you don't know the high until the bar is done. And survivorship / single-symbol overfit: one instrument over one date range is an anecdote, not evidence. Re-run across several symbols and several non-overlapping windows before you believe the shape of the equity curve.

None of this makes the exercise pointless — it makes it a filter. A strategy that can't survive frictionless bars certainly won't survive real ones, so this is a cheap way to kill bad ideas fast.

From backtest to live — same candle source

The quiet advantage of pulling candles from a hosted API is that research and production read from the same place. The client.candles.get(...) call you used to backtest is the identical call your live bot makes on each new bar to recompute its averages — no separate historical-data vendor, no format mismatch between what you tested on and what you trade on.

A live loop is the same three stages, just repeated: fetch the latest bars, recompute fast/slow, and act when position flips. Pair it with a live tick stream to time the entry precisely (see Streaming Live MT5 Tick Data over WebSocket in Python), and place the order through the trade API (see Place Your First MT5 Trade over a REST API).

One production note: history calls can hit a transient hiccup like anything over a network, so wrap them:

from tickerall import TickerallServiceUnavailableError, TickerallValidationError

try:
    bars = client.candles.get(
        session.account_id, symbol="BTCUSDm", count=500, timeframe="M15",
    )
except TickerallValidationError as e:
    raise SystemExit(f"bad request — check symbol/timeframe: {e}")
except TickerallServiceUnavailableError as e:
    if e.transient:
        ...  # back off and retry; the condition is temporary
    raise

TickerallValidationError means the request itself was wrong (an unknown symbol, two bounds passed at once) and won't fix itself on retry; TickerallServiceUnavailableError with .transient is worth a backoff-and-retry.

Wrapping up

Backtesting doesn't need a running terminal or a data vendor — it needs candles in a Python list and an honest accounting of costs. Fetch bars by count, by hours, or deep with a coarse timeframe; compute your signal; walk it forward without peeking at the future; and remember that the frictionless number is a ceiling, not a forecast. When the idea survives realistic spread across several symbols and windows, the same candle call carries it straight into live trading.

Full candle, streaming, and trade references are in the TickerAll docs.

Was this useful?

Comments

Leave a comment

Comments are public.
Backtesting a MetaTrader 5 Strategy with Historical Candles in Python · Ticker All!