# Build an MT5 trade copier with the API

URL: https://docs.fxapis.com/guides/copy-trading

> Copy trades from a master MetaTrader 5 account to many follower accounts over HTTP: detect the master's deals by polling, fan out with multi-account orders…



A trade copier watches one **master** account and repeats what happens there
on a set of **follower** accounts: an entry becomes an entry on every follower,
sized for each; a close becomes a close; a moved stop becomes a moved stop.

fxapis has the parts: the master's broker deals and positions to detect what
happened, [multi-account orders](/guides/multi-account-orders) to fan an entry
out in one call, and closes and stop changes per account. This guide puts them
together into a working copier, and is plain about its limits.

<Callout type="warn" title="Build it on demo accounts">
  A copier multiplies every mistake by the number of followers. Every fxapis key reaches real
  brokers — `fx_test_` is only a label — so run the master and every follower on **broker demo
  accounts** until the copier behaves exactly as you expect.
</Callout>

## How it works [#how-it-works]

```
every 2 s:
  POST /v1/accounts/{master}/reconcile        pull the master's latest deals and positions
  GET  /v1/accounts/{master}/deals?since=…    what happened since last time
  GET  /v1/accounts/{master}/positions        current stops, for SL/TP changes

  deal entry "in"   → POST /v1/execution-waves                     (one call, every follower)
  deal entry "out"  → POST /v1/accounts/{follower}/positions/{ticket}/close   (per follower)
  stop moved        → POST /v1/accounts/{follower}/positions/{ticket}/modify  (per follower)
```

There are no event webhooks yet, so the copier **polls**. That sets its
latency: a master trade is seen on the next poll after it happens, and copied a
moment later. See [Limitations](#limitations).

## Accounts and modes [#accounts-and-modes]

* **Master:** connect it with `"mode": "always_on"` so it stays online and its deals can be read at
  any moment. Deals are pulled from the broker by `reconcile`, which only works while the account
  is online. The copier uses the master's credentials only to read; it never trades on it.
* **Followers:** `always_on` too, if entries must be copied within seconds. A follower that is
  offline when the master trades has to log in first (about ten seconds in our tests), and a multi-account order
  may skip an account that is not online when it is created. `always_on` is a plan feature; see
  [Accounts → Modes](/accounts#modes).

The copier's key needs `trading:execute` (it places orders), `trading:read`
(reconcile, positions) and `history:read`.

## Detecting the master's trades [#detecting-the-masters-trades]

Every change to an MT5 account is recorded by the broker as a **deal** with a
unique `brokerDealId`. The copier reads them with
[`GET /v1/accounts/{id}/deals`](/api-reference/history/an-accounts-full-deal-history)
after a [`reconcile`](/api-reference/trading/confirm-pending-results-with-the-broker-now)
has brought the record up to date. The fields it uses:

| Field              | Use                                                                                                                                 |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `brokerDealId`     | Unique per deal. The copier's idempotency anchor.                                                                                   |
| `dealType`         | `buy` or `sell` for trades. Others — `balance`, `commission`, `credit`… — are ignored.                                              |
| `entry`            | `in` opens exposure, `out` closes it. `inout` (a reversal on a netting account) and `out_by` (close-by) need handling of their own. |
| `brokerPositionId` | Which master position the deal belongs to. Links follower positions to it.                                                          |
| `symbol`, `volume` | What to copy. `volume` is a string.                                                                                                 |
| `dealtAt`          | The broker's time, in UTC. The copier resumes from the last one it processed.                                                       |

Deals come newest first, 50 per page, with a cursor. The copier asks for
everything since the last deal it handled, processes them oldest first, and
remembers each `brokerDealId` it has handled.

Stop-loss and take-profit changes are not deals. The copier sees them by
comparing the master's `stopLoss` and `takeProfit` in
[`GET /v1/accounts/{id}/positions`](/api-reference/trading/positions-on-an-account)
with what it saw last time.

## Sizing: the balance ratio [#sizing-the-balance-ratio]

The usual rule is proportional: a follower with half the master's balance trades
half the master's volume. fxapis does not report account balance or equity, so
the ratio comes from your own records — the balance each follower funded with,
an allocation they chose, or a fixed multiplier — and is kept up to date by
your application.

```
follower volume = master deal volume × multiplier, rounded DOWN to the volume step
```

Round down, never up, and skip a follower whose result is below the symbol's
minimum volume rather than rounding it up to the minimum: rounding up quietly
gives small accounts more risk than the master took. Use a decimal type and
send strings.

## Idempotency per master deal [#idempotency-per-master-deal]

A copier must never copy the same master deal twice — after a crash, a restart,
a slow poll that overlaps the next one. Two things make that hold:

1. **A record of handled deals**, keyed by `brokerDealId`, written after each deal is handled.
2. **Idempotency keys derived from the deal**, so that if the copier crashes after sending but
   before recording, the resend is recognised:
   * the entry fan-out: `copy:open:{brokerDealId}` on the multi-account order;
   * each follower close: `copy:close:{brokerDealId}:{followerAccountId}`.

With both, the worst a crash can cause is asking the same question twice and
getting the same answer. See [Idempotent MT5 orders](/guides/idempotency).

## A complete copier [#a-complete-copier]

This is a small but complete copier in Python with `requests` and SQLite. It
handles one master and any number of followers, copies entries and full or
partial closes, mirrors stop changes, and survives restarts.

```bash
pip install requests
export FXAPIS_KEY="fx_test_…"
export MASTER_ACCOUNT_ID="0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51"
# follower account id = volume multiplier (for example follower balance ÷ master balance)
export FOLLOWERS="5b2e8a10-93c4-4f7d-8e61-0a9c2b4d6e13=0.5,c41d7e22-6f0b-4a95-b3e8-7d1f5a2c9b80=2"
python3 copier.py
```

```python title="copier.py"
"""A minimal MT5 trade copier on fxapis: one master, many followers.

Copies entries, full and partial closes, and stop-loss / take-profit changes.
Run it on broker DEMO accounts first. Every fxapis key reaches real brokers.
"""
import os
import sqlite3
import time
from datetime import datetime, timezone
from decimal import ROUND_DOWN, Decimal

import requests

API = os.environ.get("FXAPIS_API", "https://api.fxapis.com")
MASTER = os.environ["MASTER_ACCOUNT_ID"]
# follower account id -> volume multiplier, e.g. "id1=0.5,id2=2"
FOLLOWERS = {
    account_id: Decimal(multiplier)
    for account_id, multiplier in (item.split("=") for item in os.environ["FOLLOWERS"].split(","))
}
VOLUME_STEP = Decimal("0.01")   # check your symbols' volume step and minimum
MIN_VOLUME = Decimal("0.01")
POLL_SECONDS = 2
RETRY_SAME_KEY = {"SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED"}

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FXAPIS_KEY']}"

db = sqlite3.connect("copier.db")
db.executescript("""
create table if not exists handled_deals (deal_id text primary key);
create table if not exists links (          -- a follower position copied from a master position
    master_position text, follower text, follower_position text, volume text,
    primary key (master_position, follower));
create table if not exists pending (        -- follower orders whose result is still unknown
    order_id text primary key, master_position text, follower text, volume text);
create table if not exists stops (master_position text primary key, stop_loss text, take_profit text);
create table if not exists cursor (id integer primary key check (id = 1), since text);
""")


class ApiError(Exception):
    def __init__(self, status, code, message, details=None):
        super().__init__(f"{status} {code}: {message}")
        self.code, self.details = code, details or []


def api(method, path, attempts=5, **kwargs):
    """Reads and non-order writes: retried on 429 and 5xx, which is safe for them."""
    for attempt in range(attempts):
        try:
            r = session.request(method, f"{API}{path}", timeout=60, **kwargs)
        except requests.RequestException:
            time.sleep(min(2 ** attempt, 10))
            continue
        if r.status_code < 300:
            return r.json().get("data")
        if r.status_code == 429 or r.status_code >= 500:
            time.sleep(int(r.headers.get("retry-after", 0)) or min(2 ** attempt, 10))
            continue
        error = r.json().get("error", {})
        raise ApiError(r.status_code, error.get("code"), error.get("message"), error.get("details"))
    raise RuntimeError(f"{method} {path} kept failing")


def place(path, body, key, attempts=6):
    """Orders and closes: the same key on every retry, and ORDER_UNRESOLVED is never resent."""
    for attempt in range(attempts):
        try:
            r = session.post(f"{API}{path}", json=body, headers={"Idempotency-Key": key}, timeout=90)
        except requests.RequestException:
            time.sleep(min(2 ** attempt, 10))
            continue
        if r.status_code in (200, 201):          # 200: a multi-account order replayed by its key
            return r.json()["data"]
        try:
            error = r.json()["error"]
        except (ValueError, KeyError):
            time.sleep(min(2 ** attempt, 10))
            continue
        if error["code"] in RETRY_SAME_KEY:
            time.sleep(int(r.headers.get("retry-after", 0)) or min(2 ** attempt, 10))
            continue
        raise ApiError(r.status_code, error["code"], error["message"], error.get("details"))
    raise RuntimeError(f"gave up on {key}; it is safe to retry with the same key")


def scaled(volume, multiplier):
    lots = (Decimal(volume) * multiplier / VOLUME_STEP).to_integral_value(rounding=ROUND_DOWN) * VOLUME_STEP
    return str(lots) if lots >= MIN_VOLUME else None     # too small to copy: skip, never round up


def new_deals():
    row = db.execute("select since from cursor").fetchone()
    if row is None:
        # First start: copy from now on, not the master's history.
        since = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
        db.execute("insert into cursor (id, since) values (1, ?)", (since,))
        db.commit()
        return []
    deals, cursor = [], None
    while True:
        params = {"since": row[0], "limit": 200}
        if cursor:
            params["cursor"] = cursor
        r = session.get(f"{API}/v1/accounts/{MASTER}/deals", params=params, timeout=60)
        r.raise_for_status()
        body = r.json()
        deals.extend(body["data"])
        if not body["page"]["hasMore"]:
            break
        cursor = body["page"]["nextCursor"]
    handled = {d for (d,) in db.execute("select deal_id from handled_deals")}
    fresh = [d for d in deals if d["brokerDealId"] not in handled and d["dealType"] in ("buy", "sell")]
    return sorted(fresh, key=lambda d: (d["dealtAt"], d["brokerDealId"]))


def link(order, master_position, follower, volume):
    if order["state"] in ("filled", "partially_filled") and order.get("brokerPositionId"):
        db.execute("insert or ignore into links values (?, ?, ?, ?)",
                   (master_position, follower, order["brokerPositionId"], order.get("filledVolume") or volume))
    elif order["state"] == "unknown":
        db.execute("insert or ignore into pending values (?, ?, ?, ?)",
                   (order["id"], master_position, follower, volume))


def copy_entry(deal, master_positions):
    weights = {f: v for f, v in ((f, scaled(deal["volume"], m)) for f, m in FOLLOWERS.items()) if v}
    if not weights:
        return
    source = master_positions.get(deal["brokerPositionId"], {})
    body = {
        "accountIds": list(weights),
        "symbol": deal["symbol"],
        "side": deal["dealType"],
        "volume": next(iter(weights.values())),
        "weights": weights,
        "barrierPolicy": "release-ready",
        "clientWaveId": f"copy:{deal['brokerDealId']}",
    }
    for level in ("stopLoss", "takeProfit"):
        if source.get(level):
            body[level] = source[level]
    wave = place("/v1/execution-waves", body, key=f"copy:open:{deal['brokerDealId']}")

    while wave["state"] not in ("settled", "cancelled", "abandoned"):
        time.sleep(0.5)
        wave = api("GET", f"/v1/execution-waves/{wave['id']}")
    print(f"copied deal {deal['brokerDealId']}: {wave['summary']} spread {wave['dispatchSpreadMs']} ms")

    for leg in wave["legs"]:
        if leg["orderId"] and leg["state"] in ("filled", "unresolved"):
            order = api("GET", f"/v1/orders/{leg['orderId']}")
            link(order, deal["brokerPositionId"], leg["accountId"], leg["volume"])
        elif leg["state"] in ("rejected", "skipped"):
            print(f"  {leg['accountId']} not copied: {leg['state']} ({leg['stateDetail']})")


def copy_exit(deal, master_positions):
    master_position = deal["brokerPositionId"]
    still_open = master_positions.get(master_position)
    links = db.execute("select follower, follower_position, volume from links where master_position = ?",
                       (master_position,)).fetchall()
    for follower, ticket, volume in links:
        body = {}
        if still_open and still_open.get("volume"):
            # Partial close: close the same fraction of the follower's position.
            before = Decimal(still_open["volume"]) + Decimal(deal["volume"])
            part = scaled(volume, Decimal(deal["volume"]) / before)
            if part is None:
                continue                                  # this follower's share is below the minimum
            if Decimal(part) < Decimal(volume):
                body = {"volume": part}
        try:
            order = place(f"/v1/accounts/{follower}/positions/{ticket}/close", body,
                          key=f"copy:close:{deal['brokerDealId']}:{follower}")
        except ApiError as error:
            if error.code == "POSITION_NOT_FOUND":       # already closed (a stop hit, or closed by hand)
                db.execute("delete from links where master_position = ? and follower = ?", (master_position, follower))
                continue
            if error.code == "ORDER_UNRESOLVED":         # never resend: it is being confirmed
                print(f"  close on {follower} unresolved: order {error.details[0]['orderId']}")
                continue
            print(f"  close on {follower} failed: {error}")
            continue
        if body:
            remaining = Decimal(volume) - Decimal(order.get("filledVolume") or body["volume"])
            db.execute("update links set volume = ? where master_position = ? and follower = ?",
                       (str(remaining), master_position, follower))
        else:
            db.execute("delete from links where master_position = ? and follower = ?", (master_position, follower))


def mirror_stops(master_positions):
    for ticket, position in master_positions.items():
        levels = (position.get("stopLoss"), position.get("takeProfit"))
        row = db.execute("select stop_loss, take_profit from stops where master_position = ?", (ticket,)).fetchone()
        if row is not None and tuple(row) != levels:
            for follower, follower_position in db.execute(
                    "select follower, follower_position from links where master_position = ?", (ticket,)).fetchall():
                try:
                    # null removes a level; the same absolute prices as the master
                    api("POST", f"/v1/accounts/{follower}/positions/{follower_position}/modify",
                        json={"stopLoss": levels[0], "takeProfit": levels[1]})
                except ApiError as error:
                    print(f"  stops on {follower} not moved: {error}")
        db.execute("insert or replace into stops values (?, ?, ?)", (ticket, *levels))


def resolve_pending():
    for order_id, master_position, follower, volume in db.execute("select * from pending").fetchall():
        order = api("GET", f"/v1/orders/{order_id}")
        if order["state"] != "unknown":
            db.execute("delete from pending where order_id = ?", (order_id,))
            link(order, master_position, follower, volume)


def tick():
    api("POST", f"/v1/accounts/{MASTER}/reconcile")      # pull the master's latest deals and positions
    master_positions = {p["brokerPositionId"]: p for p in api("GET", f"/v1/accounts/{MASTER}/positions")}
    for deal in new_deals():
        if deal["entry"] == "in":
            copy_entry(deal, master_positions)
        elif deal["entry"] == "out":
            copy_exit(deal, master_positions)
        else:
            print(f"deal {deal['brokerDealId']} has entry {deal['entry']}: not copied, review it")
        db.execute("insert or ignore into handled_deals values (?)", (deal["brokerDealId"],))
        db.execute("update cursor set since = ? where id = 1", (deal["dealtAt"],))
        db.commit()
    mirror_stops(master_positions)
    resolve_pending()
    db.commit()


if __name__ == "__main__":
    while True:
        try:
            tick()
        except Exception as error:      # keep copying; the state in SQLite makes a retry safe
            print(f"tick failed: {error}")
        time.sleep(POLL_SECONDS)
```

What to notice in it:

* **Entries fan out in one call.** The master's deal becomes one multi-account order with a
  volume per follower in `weights`, carrying the master's stop loss and take profit.
* **Each follower position is linked to its master position.** After the fan-out, each filled
  leg's order gives the follower's `brokerPositionId`; closes and stop changes use that link.
* **Unresolved legs are never resent.** They wait in `pending` until their order leaves `unknown`,
  then get linked like any other.
* **Closes are proportional.** A partial close on the master closes the same fraction on each
  follower, rounded down; a full close closes the follower's whole position.
* **A follower already flat is fine.** `POSITION_NOT_FOUND` on a close means its stop or take
  profit fired first, or its owner closed it — the link is dropped.

## Mirroring stop-loss and take-profit [#mirroring-stop-loss-and-take-profit]

The copier compares each master position's `stopLoss` and `takeProfit` with
the last values it saw and, when either changes, sends the same absolute prices
to every linked follower with
[`modify`](/api-reference/trading/move-a-positions-stop-loss-or-take-profit).
`null` removes a level; an omitted field would leave it unchanged.

Absolute prices are right for followers on the same broker. Across brokers,
prices differ by a few points and a level valid on one may be too close to the
market on another; the broker refuses those, and the copier logs it.

Stop losses and take profits live at each follower's broker, so they fire on
their own even when the copier is not running.

## Limitations [#limitations]

Be clear with your users about these:

* **Polling latency.** Detection happens on each poll — every two seconds in the example — so a
  follower's entry lands a few seconds after the master's, at the market price at that moment.
  For scalping strategies that difference can matter. In our benchmark against an instant broker,
  the fan-out itself spreads orders over about 30 ms across 100 accounts, plus each broker's own
  latency; every multi-account order reports its own spread.
* **No event webhooks yet.** There is no push notification of a master's trade; the copier must
  poll. `reconcile` pulls deals from the broker, and only while the master is online.
* **Account balance is not reported.** Size from your own records, as above.
* **Symbol names differ between brokers.** `EURUSD` on one may be `EURUSD.a` or `EURUSDm` on another.
  Add a per-follower symbol map if your followers use different brokers.
* **Netting reversals and close-by.** Deals with `entry` `inout` or `out_by` are logged, not copied.
  Handle them explicitly if your masters use them.
* **Pending orders are not copied** until they fill; the fill then arrives as an `in` deal and is
  copied as a market entry.
* **MT5 only.** fxapis does not connect MT4 accounts.

## Related [#related]

* [One order across many accounts](/guides/multi-account-orders) — barrier policies and results in depth.
* [Idempotent MT5 orders](/guides/idempotency) — why the keys are shaped like this.
* [Monitoring trader accounts](/guides/prop-firms) — reading deals and positions for rule checks.
* [Migrating from MetaApi](/guides/migrate-from-metaapi) — if you are coming from CopyFactory.
