MT5 historical data API: candles and ticks into pandas

9 min readMetaKit

mt5.copy_rates_range() works beautifully on the Windows laptop where you wrote the notebook, and nowhere else. The moment the research job needs to run on a Linux box, in a scheduled container, or on the same server as your API, the MetaTrader5 package is out and you're back to exporting CSVs from the terminal by hand.

Here's the other route: MT5 historical data through a REST API, straight into pandas, including the parts that go wrong in practice: the request caps, the backfill loop, and the timestamp handling.

What the broker feed gives you, and what it doesn't

Historical prices in MT5 come from the broker your account is logged into. That has three consequences worth knowing before you write a line of code.

History depth depends on the broker. Some serve years of M1; some serve a few months and thin out beyond that; a few serve deep D1 but shallow intraday. The API can only return what the terminal can load from that server. If you ask for M1 bars from 2019 and get nothing back, that's the broker, not the endpoint.

The candles are that broker's candles. Bid-based bars, that broker's spread, that broker's session boundaries. Two accounts at two brokers produce slightly different EURUSD H1 series. Fine for research; annoying if you expected them to match a reference feed to the pip.

Ticks are much heavier than candles. One busy day of EURUSD is hundreds of thousands of ticks; the same day is 1,440 M1 bars. Tick history is also shallower than bar history at most brokers. Use candles unless you genuinely need bid/ask at sub-minute resolution.

The MT5 candles endpoint

GET /v1/accounts/{id}/candles?symbol=EURUSD&timeframe=H1&count=500
ParamNotes
symbolRequired. Use the account's exact symbol name (XAUUSD, EURUSD.pro, whatever the broker calls it).
timeframeM1 M5 M15 M30 H1 H4 D1 W1 MN1. Default M1.
from / toBoth present: every bar in that range. from only: count bars starting there.
countDefault 500, max 5000. Used when to is absent.

With neither from nor to you get the most recent count bars, which is what a chart wants. The response isn't the usual pagination envelope:

{
  "data": [
    { "time": "2026-07-28T09:00:00.000Z", "open": 1.16401, "high": 1.16455,
      "low": 1.16388, "close": 1.16433, "tickVolume": 1843, "spread": 12, "realVolume": 0 }
  ],
  "symbol": "EURUSD",
  "timeframe": "H1",
  "count": 1,
  "truncated": false
}

time is the bar's open. realVolume is almost always 0 on FX because brokers don't report it; tickVolume is the activity measure you actually get. If a range request hits the 5,000-bar cap, truncated is true and a message field says so. Note the two field names in camelCase; they come through from the terminal as-is.

A read-only API key is enough for everything here. The account has to be connected, or you'll get a 502 upstream_error instead of data.

The ticks endpoint

GET /v1/accounts/{id}/ticks?symbol=EURUSD&from=2026-07-28T09:00:00Z&to=2026-07-28T10:00:00Z

Ticks have two modes and the presence of to decides which. from plus to is a true range query and returns every tick in the window. from plus count returns N ticks starting at from, covering however much wall-clock time that happens to be. to without from is a 400. The cap is 50,000 rows and count defaults to 500.

Each tick is time, bid, ask, last, volume. On FX last and volume are usually zero.

Backfilling a range without hitting the caps

A single wide request will truncate. The pattern is to walk the range in windows small enough to stay under the cap, and check truncated on every page so you find out when a window was still too wide (a news day on gold will do it).

For candles, size the window from the timeframe. M1 is 1,440 bars per day, so three days is safe under 5,000; H1 is 24 per day, so six months fits in one call. For ticks, one day is the starting window and you split further when truncated comes back true.

import os
import time
from datetime import datetime, timedelta, timezone
 
import pandas as pd
import requests
 
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
 
BARS_PER_DAY = {
    "M1": 1440, "M5": 288, "M15": 96, "M30": 48,
    "H1": 24, "H4": 6, "D1": 1, "W1": 1 / 7, "MN1": 1 / 30,
}
 
 
def fetch_candles(account_id: int, symbol: str, timeframe: str,
                  start: datetime, end: datetime) -> list[dict]:
    """Walk [start, end) in windows that stay under the 5000-bar cap."""
    days_per_window = max(1, int(4500 / BARS_PER_DAY[timeframe]))
    window = timedelta(days=days_per_window)
    rows: list[dict] = []
    cursor = start
 
    while cursor < end:
        stop = min(cursor + window, end)
        res = requests.get(
            f"{BASE}/v1/accounts/{account_id}/candles",
            params={
                "symbol": symbol,
                "timeframe": timeframe,
                "from": cursor.isoformat(),
                "to": stop.isoformat(),
            },
            headers=HEADERS,
            timeout=60,
        )
        if not res.ok:
            err = res.json()["error"]
            raise RuntimeError(f"{res.status_code} {err['code']}: {err['message']}")
 
        page = res.json()
        if page["truncated"]:
            # Halve the window and retry this stretch.
            window = window / 2
            if window < timedelta(hours=1):
                raise RuntimeError(f"cannot get under the cap at {cursor}")
            continue
 
        rows.extend(page["data"])
        cursor = stop
        time.sleep(0.2)  # be polite; each call reaches a real terminal
 
    return rows

The loop is deliberately boring. It's also idempotent per window, so if you persist as you go and crash halfway, you restart from the last cursor.

Into a DataFrame with a proper DatetimeIndex

The API returns ISO 8601 timestamps with a Z suffix. Parse them with utc=True so pandas gives you a timezone-aware index rather than naive datetimes. One caveat the next section explains: for candles and ticks the clock behind that Z is the broker's server clock, so treat the index as server time until you've applied the offset.

def to_frame(rows: list[dict]) -> pd.DataFrame:
    df = pd.DataFrame(rows)
    if df.empty:
        return df
    df["time"] = pd.to_datetime(df["time"], utc=True)
    df = (
        df.rename(columns={"tickVolume": "tick_volume", "realVolume": "real_volume"})
          .drop_duplicates(subset="time")
          .set_index("time")
          .sort_index()
    )
    return df[["open", "high", "low", "close", "tick_volume", "spread", "real_volume"]]

drop_duplicates is there because adjacent windows can share a boundary bar depending on how the terminal treats an inclusive to. Cheap insurance.

The server-time trap

The terminal reports bar and tick times in server time, the broker's clock, and MetaKit passes those values through and formats them as ISO 8601 with a Z. Nothing in between knows the broker's offset, so a Z on candle data means "server time, formatted like UTC", not "converted to UTC". Many brokers run their server clock on Eastern European time so that the trading week's five daily bars line up with New York's 5pm rollover, which puts them two or three hours ahead of UTC depending on daylight saving.

The practical consequence: your D1 candle for Monday opens at 00:00Z in the response, and that instant is really 21:00 or 22:00 UTC on Sunday. Anything keyed on calendar days needs the offset applied first. Find it by comparing a known tick to a reference clock, store it per broker, and shift the index with df.index - pd.Timedelta(hours=offset) before joining to data from anywhere else. If you want UTC-aligned daily bars, build them from the shifted H1 frame (below) rather than trusting D1. The server time post has the full explanation of why brokers do this and how to detect the offset.

Resampling

With a proper index, resampling is one line. Pull H1 and build H4, or build your UTC-day bars:

OHLC = {
    "open": "first", "high": "max", "low": "min", "close": "last",
    "tick_volume": "sum", "spread": "mean", "real_volume": "sum",
}
 
h4 = h1.resample("4h", label="left", closed="left").agg(OHLC).dropna(subset=["open"])
daily_utc = h1.resample("1D").agg(OHLC).dropna(subset=["open"])

label="left", closed="left" keeps the "time is the bar's open" convention the API uses. dropna removes the empty weekend buckets that resample creates whether or not there was data.

One caveat from the docs: for a live chart, ask for the timeframe you display rather than fetching M1 and aggregating on every refresh. For a research dataset you fetch once, resampling is exactly what you want.

What this is good for

Research and backtesting at bar resolution. Feature engineering (rolling volatility, session ranges, spread by hour). Dashboards that show what a strategy has been trading against. Reconciling your own trade log against the price at the time. Anything where "the candle was fetched a few hundred milliseconds after it closed" is fine.

What it's not for is high-frequency work. Every request crosses the internet to a hosted terminal, and the data is the broker's retail feed, not exchange depth. If you're modelling microstructure you need a different source altogether, and if you're building an execution engine that reacts to ticks, a REST hop is the wrong shape. For that class of problem the local Python package on a Windows machine next to the terminal is genuinely the better tool, and we'd say so.

The complete script

import os
import time
from datetime import datetime, timedelta, timezone
 
import pandas as pd
import requests
 
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
ACCOUNT_ID = int(os.environ.get("METAKIT_ACCOUNT_ID", "2"))
 
BARS_PER_DAY = {
    "M1": 1440, "M5": 288, "M15": 96, "M30": 48,
    "H1": 24, "H4": 6, "D1": 1, "W1": 1 / 7, "MN1": 1 / 30,
}
OHLC = {
    "open": "first", "high": "max", "low": "min", "close": "last",
    "tick_volume": "sum", "spread": "mean", "real_volume": "sum",
}
 
 
def fetch_candles(account_id, symbol, timeframe, start, end):
    days_per_window = max(1, int(4500 / BARS_PER_DAY[timeframe]))
    window = timedelta(days=days_per_window)
    rows, cursor = [], start
    while cursor < end:
        stop = min(cursor + window, end)
        res = requests.get(
            f"{BASE}/v1/accounts/{account_id}/candles",
            params={"symbol": symbol, "timeframe": timeframe,
                    "from": cursor.isoformat(), "to": stop.isoformat()},
            headers=HEADERS, timeout=60,
        )
        if not res.ok:
            err = res.json()["error"]
            raise RuntimeError(f"{res.status_code} {err['code']}: {err['message']}")
        page = res.json()
        if page["truncated"]:
            window = window / 2
            if window < timedelta(hours=1):
                raise RuntimeError(f"cannot get under the cap at {cursor}")
            continue
        rows.extend(page["data"])
        cursor = stop
        time.sleep(0.2)
    return rows
 
 
def to_frame(rows):
    df = pd.DataFrame(rows)
    if df.empty:
        return df
    df["time"] = pd.to_datetime(df["time"], utc=True)
    df = (df.rename(columns={"tickVolume": "tick_volume", "realVolume": "real_volume"})
            .drop_duplicates(subset="time").set_index("time").sort_index())
    return df[["open", "high", "low", "close", "tick_volume", "spread", "real_volume"]]
 
 
if __name__ == "__main__":
    status = requests.get(f"{BASE}/v1/accounts/{ACCOUNT_ID}", headers=HEADERS).json()["status"]
    if status != "connected":
        raise SystemExit(f"account is {status}, not connected")
 
    end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
    start = end - timedelta(days=90)
 
    h1 = to_frame(fetch_candles(ACCOUNT_ID, "XAUUSD", "H1", start, end))
    print(f"{len(h1)} H1 bars from {h1.index[0]} to {h1.index[-1]}")
 
    h4 = h1.resample("4h", label="left", closed="left").agg(OHLC).dropna(subset=["open"])
    daily = h1.resample("1D").agg(OHLC).dropna(subset=["open"])
 
    h1["ret"] = h1["close"].pct_change()
    h1["vol_24h"] = h1["ret"].rolling(24).std()
 
    h1.to_parquet("xauusd_h1.parquet")
    daily.to_parquet("xauusd_d1_utc.parquet")
    print(daily.tail())

Ninety days of XAUUSD H1 is a single request (2,160 bars). The same in M1 is about thirty. Ticks for the same period would be a different afternoon.

If you're connecting the account for the first time, the Python connect tutorial covers the status lifecycle; parameter details for both endpoints are in llms.txt.