Eight accounts, four brokers, four terminals open on two monitors, and a spreadsheet that gets updated by hand at 5pm if nobody forgets. That's how most people running multiple MT5 accounts actually track them, and it works right up until the day one terminal silently loses its connection at 9am and you find out at 5pm.
If you manage money across several MetaTrader accounts, or you're juggling a handful of prop challenges at once, you need one table: every account, its equity, its floating P&L, its worst drawdown, and your net exposure per symbol across all of them. What follows builds that table in about a hundred lines of Python, then puts it on a schedule.
One terminal per account doesn't scale
An MT5 terminal is logged into exactly one account at a time. You can install it several times with separate data directories, and people do, but every copy is a Windows process that wants RAM, wants to be logged in, and wants to be restarted after the broker pushes an update. At five accounts it's tedious. At fifteen it's a part-time job.
The official MetaTrader5 Python package doesn't help, because it binds to one
running terminal on the same machine. To read fifteen accounts you need fifteen
terminals and a script that initialises against each in turn, which is exactly
the thing you were trying to escape.
The failure mode that actually costs money isn't the tedium. It's the account you didn't look at. A terminal that disconnected at 9am shows you a frozen equity figure all day, and nothing about the number tells you it's stale.
Track multiple MetaTrader accounts from one script
The approach here uses MetaKit, which runs each
account in its own hosted terminal and exposes everything over REST. The
important property for this job is that every account is addressable the same
way regardless of broker: /v1/accounts/{id} for live figures,
/v1/accounts/{id}/positions for open trades,
/v1/accounts/{id}/performance for computed drawdown. Read-only slots (the
investor password) are enough for all of it.
Start by listing the accounts. The list is paginated, so walk the pages:
import os
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
def get(path, **params):
res = requests.get(f"{BASE}{path}", params=params, headers=HEADERS, timeout=20)
if not res.ok:
err = res.json()["error"]
raise RuntimeError(f"{path}: {err['code']}: {err['message']}")
return res.json()
def list_accounts():
accounts, page = [], 1
while True:
body = get("/v1/accounts", page=page, limit=100)
accounts.extend(body["data"])
if page >= body["total_pages"]:
return accounts
page += 1Each account object already carries balance, equity, used_margin,
free_margin, currency and status. Check status before you trust the
numbers: anything other than connected returns null or 0 for the live
fields, and the data endpoints return a 502 upstream_error. That's your
stale-terminal detector, for free. An account that isn't connected gets a
row that says so, not a row with yesterday's equity on it.
Pull every account concurrently
Sequentially, fifteen accounts times three requests each at a couple of hundred milliseconds a call is a ten-second report. Not terrible, but pointless. The calls are independent, so run them in a thread pool:
from concurrent.futures import ThreadPoolExecutor
def list_positions(account_id):
positions, page = [], 1
while True:
body = get(f"/v1/accounts/{account_id}/positions", page=page, limit=100)
positions.extend(body["data"])
if page >= body["total_pages"]:
return positions
page += 1
def snapshot(account):
"""One account's worth of data. Runs in a worker thread."""
if account["status"] != "connected":
return {"account": account, "skipped": account["status"]}
return {
"account": account,
"positions": list_positions(account["id"]),
"perf": get(f"/v1/accounts/{account['id']}/performance", range="1m"),
}
with ThreadPoolExecutor(max_workers=8) as pool:
rows = list(pool.map(snapshot, list_accounts()))requests is thread-safe enough for this. If you'd rather be async, httpx
with asyncio.gather is the same shape. Keep the worker count modest; each
request lands on a real terminal, and there's no prize for hammering eight of
them at once.
The /performance call is the expensive one. It reconstructs trades from deal
history and returns max_drawdown_pct against the equity high-water mark,
which is the number a money manager is asked about. The range param
(1m, 3m, 6m, 12m, all) picks the window.
Normalise currencies before you add anything up
Adding a EUR balance to a USD balance to a GBP balance gives you a number that means nothing, and it's an easy mistake to make when the API just returns plain numbers in each account's own currency.
You need a rate per currency. You can pull one from any FX data source, but you already have a broker feed on every connected account, so the cheapest option is to ask one of your own accounts for the quote:
REPORT_CCY = "USD"
def fx_rate(quote_account_id, ccy):
"""Mid-rate from one of your own broker feeds. Fine for a dashboard."""
if ccy == REPORT_CCY:
return 1.0
try:
spec = get(f"/v1/accounts/{quote_account_id}/symbols/{ccy}{REPORT_CCY}")
return (spec["bid"] + spec["ask"]) / 2 # EURUSD -> EUR in USD
except RuntimeError:
spec = get(f"/v1/accounts/{quote_account_id}/symbols/{REPORT_CCY}{ccy}")
return 2 / (spec["bid"] + spec["ask"]) # USDJPY -> JPY in USDThe caveat, stated plainly: this is a mid-rate snapshot from one broker at the
moment the script ran. It's right for a dashboard and wrong for accounting.
Your actual conversion when you withdraw is whatever the bank or the broker
gives you that day. Also, if the quoting broker uses symbol suffixes
(EURUSD.pro, EURUSD.r), pass the suffix in; symbol lookups are exact.
One table: accounts, totals, exposure, worst drawdown
Now combine. Per-account rows in native currency, totals in the reporting currency, and two things the terminals can never show you side by side:
- Net exposure by symbol across accounts. Buys count positive, sells
negative, so a 1.0 lot long on
XAUUSDin one account and a 0.6 lot short in another nets to 0.4. Strip the broker suffix first orXAUUSDandXAUUSD.proland in different buckets. (Netting lots across accounts with different contract sizes is its own rabbit hole; see the symbol suffixes post.) - Worst drawdown in the book. The single account closest to a limit is the one that ruins the month, so surface it by name.
Floating P&L per account is the sum of profit plus swap across open
positions. The account's equity already includes it; the separate figure is
there so you can see how much of the equity is unrealised.
The complete script
import os
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
REPORT_CCY = "USD"
def get(path, **params):
res = requests.get(f"{BASE}{path}", params=params, headers=HEADERS, timeout=20)
if not res.ok:
err = res.json()["error"]
raise RuntimeError(f"{path}: {err['code']}: {err['message']}")
return res.json()
def paged(path):
items, page = [], 1
while True:
body = get(path, page=page, limit=100)
items.extend(body["data"])
if page >= body["total_pages"]:
return items
page += 1
def snapshot(account):
if account["status"] != "connected":
return {"account": account, "skipped": account["status"]}
return {
"account": account,
"positions": paged(f"/v1/accounts/{account['id']}/positions"),
"perf": get(f"/v1/accounts/{account['id']}/performance", range="1m"),
}
def fx_rate(quote_account_id, ccy):
if ccy == REPORT_CCY:
return 1.0
try:
spec = get(f"/v1/accounts/{quote_account_id}/symbols/{ccy}{REPORT_CCY}")
return (spec["bid"] + spec["ask"]) / 2
except RuntimeError:
spec = get(f"/v1/accounts/{quote_account_id}/symbols/{REPORT_CCY}{ccy}")
return 2 / (spec["bid"] + spec["ask"])
def main():
with ThreadPoolExecutor(max_workers=8) as pool:
rows = list(pool.map(snapshot, paged("/v1/accounts")))
live = [r for r in rows if "positions" in r]
if not live:
raise SystemExit("no connected accounts")
quote_id = live[0]["account"]["id"]
currencies = {r["account"]["currency"] for r in live}
rates = {ccy: fx_rate(quote_id, ccy) for ccy in currencies}
totals = defaultdict(float)
exposure = defaultdict(float)
worst = None
print(f"{'account':<22}{'ccy':<5}{'balance':>14}{'equity':>14}{'floating':>12}{'maxDD%':>8}")
for r in live:
a, rate = r["account"], rates[r["account"]["currency"]]
floating = sum(p["profit"] + p["swap"] for p in r["positions"])
dd = r["perf"]["max_drawdown_pct"]
print(f"{a['account_name']:<22}{a['currency']:<5}"
f"{a['balance']:>14,.2f}{a['equity']:>14,.2f}{floating:>12,.2f}{dd:>8.2f}")
totals["balance"] += a["balance"] * rate
totals["equity"] += a["equity"] * rate
totals["floating"] += floating * rate
if worst is None or dd < worst[1]:
worst = (a["account_name"], dd)
for p in r["positions"]:
symbol = p["symbol"].split(".")[0].upper()
lots = p["volume"] if p["type"] == "buy" else -p["volume"]
exposure[symbol] += lots
print(f"\n{'TOTAL':<22}{REPORT_CCY:<5}"
f"{totals['balance']:>14,.2f}{totals['equity']:>14,.2f}{totals['floating']:>12,.2f}")
print(f"worst drawdown: {worst[0]} at {worst[1]:.2f}%")
print("\nnet exposure (lots):")
for symbol, lots in sorted(exposure.items(), key=lambda kv: -abs(kv[1])):
print(f" {symbol:<12}{lots:>+8.2f}")
for r in rows:
if "skipped" in r:
print(f"\nSKIPPED {r['account']['account_name']}: status={r['skipped']}")
if __name__ == "__main__":
main()Run it with METAKIT_KEY set. The skipped section at the bottom is the part
that pays for the whole thing: an account in error or disconnected shows
up as a line you can't miss, instead of a frozen number you can.
Put it on a schedule
Every five minutes is plenty for a management view:
*/5 * * * * cd /opt/book && METAKIT_KEY=stk_live_... python report.py > /var/www/book.txtTwo things about cadence. Balance, equity and positions are live reads from
the terminal, so polling them every minute is fine. Deal history is cached
about 60 seconds server-side, and /performance is built from it, so calling
that endpoint every ten seconds returns the same drawdown figure ten times
over. If you want a faster loop for the live figures, split the script: poll
accounts and positions often, refresh /performance once a minute or less.
For anything that needs to react rather than report, a poller is the wrong tool. An equity monitor on each account fires the moment drawdown crosses a line, and the prop-firm drawdown post covers how to set those thresholds.
Two numbers to be careful with
max_drawdown_pct from /performance is computed on reconstructed equity
over the requested range, per account. Adding drawdowns across accounts is
meaningless; the book's drawdown is a separate calculation on the combined
equity curve, and the
Sharpe and drawdown post
explains why the two don't reconcile.
The exposure table assumes one position per row, which is what you get on a hedging account. On a netting account there's one position per symbol, already netted by the broker, so the table is correct but tells you less about how the exposure was built. The netting vs hedging post covers what changes.
The full endpoint reference, including every field on the account and position objects, is at app.metakit.cloud/docs and in llms.txt. Close the terminals.