Track multiple MT5 accounts: one P&L table over the API

8 min readMetaKit

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 += 1

Each 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 USD

The 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 XAUUSD in one account and a 0.6 lot short in another nets to 0.4. Strip the broker suffix first or XAUUSD and XAUUSD.pro land 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.txt

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