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
| Param | Notes |
|---|---|
symbol | Required. Use the account's exact symbol name (XAUUSD, EURUSD.pro, whatever the broker calls it). |
timeframe | M1 M5 M15 M30 H1 H4 D1 W1 MN1. Default M1. |
from / to | Both present: every bar in that range. from only: count bars starting there. |
count | Default 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 rowsThe 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.