# Copy trading

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

> Copy an MT5 master account's trades to follower accounts, managed by fxapis: sizing, symbol mapping, risk limits, an executions log, webhooks, and no code on…



Copy trading repeats one **master** account's trades on a set of **follower**
accounts: an open becomes an open on every follower, sized for each; a close
becomes a close; a moved stop becomes a moved stop.

fxapis runs this for you, server-side — mark the master as a **strategy**,
subscribe followers, and nothing runs on your infrastructure. If you would
rather own the copying rules yourself, the same building block it is made
from — multi-account orders — is yours to call; see the
[appendix](#appendix-build-your-own-copier).

> **Copying starts after the upcoming scheduled maintenance.** Until then
> `GET /v1/copy/status` answers `paused: true`: you can read, pause and delete
> strategies and subscriptions and change their settings, but making a
> strategy, subscribing, resuming and reset answer `503 COPY_TRADING_PAUSED`,
> nothing is copied, and no follower is billed.

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

Mark an always-on, hedging MT5 account as a strategy, then subscribe other
accounts **in the same workspace** as followers, each with its own settings.
From then on fxapis checks the master every second and copies, server-side:

* opens, including a pending order on the master once it fills;
* full closes;
* partial closes, as a proportional reduction of the follower's position;
* stop-loss and take-profit changes.

A close is confirmed on two readings about a second apart before fxapis lets
go of a follower's position, so a single bad reading from the master's
terminal never closes a follower early.

It is typically 1–2 seconds from the master's fill to the follower's order
being sent; broker execution time is extra, and varies by broker. The live
p50 and p99 of that internal latency are on
[status.fxapis.com](https://status.fxapis.com), on the Trading engine row.

<Callout type="info" title="Hedging accounts only, same workspace">
  A netting master or follower is refused with `NETTING_NOT_SUPPORTED` — copy trading copies
  between hedging accounts only. An account cannot be both a strategy's master and a follower,
  and followers must be in the same workspace as their strategy.
</Callout>

## Quick start [#quick-start]

**1. Mark an always-on account as a strategy.** The master must already be in
`always_on` mode (`POST /v1/accounts/{id}/mode`) — a master traded by hand
never sends fxapis an order, so an idle terminal would be stopped.

```bash
curl -s https://api.fxapis.com/v1/copy/strategies \
  -H "Authorization: Bearer $FXAPIS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"accountId": "0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51", "name": "Gold scalper"}'
```

```json
{
  "data": {
    "id": "a1b2c3d4-0000-4000-8000-000000000001",
    "accountId": "0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51",
    "name": "Gold scalper",
    "status": "active",
    "masterAlwaysOn": true,
    "followers": 0,
    "createdAt": "2026-10-16T12:00:00.000Z",
    "updatedAt": "2026-10-16T12:00:00.000Z"
  }
}
```

**2. Subscribe a follower.** Every setting has a default — send only what
differs (see [Settings](#settings)).

```bash
curl -s https://api.fxapis.com/v1/copy/subscriptions \
  -H "Authorization: Bearer $FXAPIS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "strategyId": "a1b2c3d4-0000-4000-8000-000000000001",
    "followerAccountId": "5b2e8a10-93c4-4f7d-8e61-0a9c2b4d6e13",
    "settings": { "sizing": { "mode": "equity_ratio" } }
  }'
```

```json
{
  "data": {
    "id": "b2c3d4e5-0000-4000-8000-000000000002",
    "strategyId": "a1b2c3d4-0000-4000-8000-000000000001",
    "followerAccountId": "5b2e8a10-93c4-4f7d-8e61-0a9c2b4d6e13",
    "status": "active",
    "stopReason": null,
    "settings": { "sizing": { "mode": "equity_ratio", "multiplier": 1 }, "...": "..." },
    "createdAt": "2026-10-16T12:00:05.000Z",
    "updatedAt": "2026-10-16T12:00:05.000Z"
  }
}
```

**3. Watch what was copied.**

```bash
curl -s "https://api.fxapis.com/v1/copy/executions?strategyId=a1b2c3d4-0000-4000-8000-000000000001" \
  -H "Authorization: Bearer $FXAPIS_KEY"
```

```json
{
  "data": [
    {
      "id": "c3d4e5f6-0000-4000-8000-000000000003",
      "strategyId": "a1b2c3d4-0000-4000-8000-000000000001",
      "subscriptionId": "b2c3d4e5-0000-4000-8000-000000000002",
      "followerAccountId": "5b2e8a10-93c4-4f7d-8e61-0a9c2b4d6e13",
      "status": "executed",
      "reason": null,
      "detail": null,
      "orderId": "d4e5f6a7-0000-4000-8000-000000000004",
      "requestedVolume": "0.40",
      "latencyMs": 820,
      "createdAt": "2026-10-16T12:05:00.000Z",
      "signal": { "kind": "open", "symbol": "XAUUSD", "side": "buy", "volumeAfter": "1.00" }
    }
  ],
  "page": { "hasMore": false, "nextCursor": null }
}
```

Each key needs `trading:execute` to create or change a strategy or
subscription, and `trading:read` to list and read. A member in the console
can do the same as an owner or admin, with no key at all. Making a strategy,
subscribing, or changing anything that moves money or resumes copying
(sizing, limits, reverse, risk limits, resuming, reset) asks a member to
confirm it is them (step-up); pausing, `close_only` and deleting never do.

## Settings [#settings]

Every subscription has its own settings, validated by one schema shared by
the API and the console — a setting the console offers is exactly one the
API takes, and an unknown field is refused rather than silently ignored.

| Setting                           | Values                                                                                                                            | Default         |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `sizing.mode`                     | `balance_ratio`, `equity_ratio`, `multiplier`, `fixed_lots`, `risk_percent`                                                       | `balance_ratio` |
| `sizing.multiplier`               | > 0, ≤ 100. Scales every ratio mode                                                                                               | `1`             |
| `sizing.lots`                     | for `fixed_lots`                                                                                                                  | —               |
| `sizing.riskPercent`              | 0.01–10, for `risk_percent`. A master trade with no stop loss is skipped (`no_stop_loss`)                                         | —               |
| `reverse`                         | boolean — buy ↔ sell, and stop loss ↔ take profit                                                                                 | `false`         |
| `copySlTp`                        | `off`, `on_open`, `always`                                                                                                        | `always`        |
| `symbols.allow` / `symbols.block` | lists of instrument names. An empty allow list means every symbol                                                                 | `[]` / `[]`     |
| `symbols.map`                     | master symbol (or instrument) → this follower's symbol                                                                            | `{}`            |
| `limits.maxLots`                  | per copied trade                                                                                                                  | none            |
| `limits.maxOpenPositions`         | on the follower, from this subscription                                                                                           | none            |
| `limits.minLots`                  | `skip` or `use_min`, when the scaled size is below the broker's minimum                                                           | `skip`          |
| `late.maxDelaySeconds`            | 1–600. An open seen later than this is skipped                                                                                    | `30`            |
| `late.maxDeviationPoints`         | `null` (off), or a number of points the follower's price may have moved from the master's fill before an open is skipped          | `null`          |
| `late.copyExistingOnStart`        | also open the master's existing positions when subscribing or resuming, within the deviation limit                                | `false`         |
| `risk.limits[]`                   | up to 8: `{period: day\|week\|month\|lifetime, metric: balance\|equity, maxLossPercent` or `maxLossAmount, closePositions: bool}` | `[]`            |

Sizing rules:

* **Balance and equity ratio:** follower value ÷ master value × multiplier,
  from each account's last figures snapshot, refreshed every 15 seconds. If
  either snapshot is more than 5 minutes old, the trade is skipped with
  `stale_figures` rather than sized on a stale number.
* **Risk percent:** sizes to the master's stop-loss distance, so the follower
  risks the same percentage of its equity that the master trade risks of the
  master's. A master trade with no stop loss cannot be sized this way
  (`no_stop_loss`).
* **Rounding:** every result is rounded *down* to the follower's volume step
  and clamped to its maximum — never up, which would risk more than the
  follower asked for.

## Late copies and offline followers [#late-copies-and-offline-followers]

* **`maxDelaySeconds`** (default 30) is measured from when fxapis detected
  the master's change, not the broker's own timestamp (broker clocks drift).
  An open seen later than that is skipped with `too_late`. **Closes always
  go through, however late** — a position is never left open on a follower
  just because the close was seen late.
* **`maxDeviationPoints`**, when set, skips an open whose follower-side price
  has moved too far from the master's fill (`price_moved`).
* A `warm_on_demand` follower that is offline when the master opens is
  started, and the open is still copied if the follower comes online before
  `maxDelaySeconds` runs out; otherwise it is skipped with
  `follower_offline`. A close or reduce waiting on an offline follower is
  kept and sent as soon as it is back, whatever the delay.
* **The drift check** runs every 15 seconds, and whenever a follower or the
  master reconnects, comparing each subscription's links against reality and
  correcting any difference: closing a follower position whose master
  position is gone, reducing one that holds more than its share, moving
  stops that differ (under `copySlTp: "always"`), and — only with
  `copyExistingOnStart` and within the deviation limit — opening a master
  position that was never linked. A correction needs two readings at least
  30 seconds apart that agree before fxapis lets go of a position on this
  account, so one odd reading from a terminal never corrects something that
  was never wrong. Each correction is logged as its own execution, with a
  `copy.drift_corrected` event.

## Risk limits [#risk-limits]

A subscription can stop itself after a loss, measured from the start of a
period — **day**, **week** (the ISO week, Monday), **month** or
**lifetime** — in UTC, as billing's days are. Up to 8 limits per
subscription, each by percent or amount, on balance or equity, and each
optionally closing the subscription's positions when it stops
(`closePositions`).

The loss is measured from the period's starting balance or equity.
Withdrawals, credits, charges and corrections are not counted as a loss, and
a deposit does not hide one — both are netted out of the figure, so a
deposit during a period raises how much loss it takes to stop the
subscription, it does not forgive loss already taken. A stop needs two
readings in a row that both breach the limit; one bad reading never stops
anything. If the account's deposits and withdrawals cannot be confirmed for
10 minutes, the subscription stops anyway, with its reason suffixed
`:unverified` — a false stop is cheaper than a true one missed. A withdrawal
the broker records more than a minute after it was made can be missed, and
then counts as a loss.

A stopped subscription stays `stopped` — copying nothing — until it is reset:

```bash
curl -s -X POST https://api.fxapis.com/v1/copy/subscriptions/{id}/reset \
  -H "Authorization: Bearer $FXAPIS_KEY"
```

Reset puts the subscription back to `active` — or to `paused`, if it was
paused when it stopped — starts every risk period's baseline afresh, from the
next figures reading, and needs a member to confirm it is them (step-up).

`limits.maxLots`, `limits.maxOpenPositions` and the symbol allow/block lists
are checked before every open too (`limit_exceeded`, `symbol_blocked`); if
the subscription was made with an API key that has limits, that key's own
limits apply as well.

## Executions and reasons [#executions-and-reasons]

Every signal fxapis acts on — a master change, a drift correction, a
risk-limit stop-out — produces one execution per subscription: `executed`,
`skipped`, `failed` or still `pending`, with a `reason` code and its `detail`
in words. `GET /v1/copy/executions` lists them, filterable by strategy,
subscription, follower, status, reason and `since`, cursor-paginated.

| Reason                  | What to do                                                                                                                                   |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `too_late`              | The change was seen too long ago to copy. Raise `late.maxDelaySeconds`, or accept the skip.                                                  |
| `price_moved`           | The follower's price moved further than `late.maxDeviationPoints` allows. Raise it, or set it to `null`.                                     |
| `follower_offline`      | The follower could not be brought online in time. Keep it `always_on`, or accept the skip.                                                   |
| `below_min_volume`      | The sized volume was below the follower's minimum and `limits.minLots` is `skip`. Set it to `use_min`, or raise `sizing.multiplier`.         |
| `no_stop_loss`          | `risk_percent` sizing needs a stop loss, and the master's trade had none. Put a stop on the master's trades, or use a different sizing mode. |
| `stale_figures`         | The follower's (or master's) balance or equity could not be read in the last 5 minutes. Make sure the account stays online.                  |
| `symbol_unmapped`       | The master's symbol has no match on the follower's broker. Add it to `symbols.map`.                                                          |
| `symbol_blocked`        | The symbol is blocked, or not on the allow list, in this subscription's settings. Change `symbols.allow` / `symbols.block`.                  |
| `limit_exceeded`        | `limits.maxLots`, `limits.maxOpenPositions`, or the creating key's own limits, stopped it. Raise the limit, or accept the skip.              |
| `not_enough_margin`     | The follower did not have the margin for it. Fund the account, or size it smaller.                                                           |
| `market_closed`         | The market was closed on the follower's broker.                                                                                              |
| `feature_not_included`  | The workspace's plan does not include copy trading. Upgrade.                                                                                 |
| `quota_exceeded`        | The workspace's plan quota (orders, environment) was used up.                                                                                |
| `broker_rejected`       | The follower's broker refused it; `detail` carries its own code.                                                                             |
| `unresolved`            | Sent, with no answer from the broker yet. Never resent; it settles once the broker's records are read.                                       |
| `netting_not_supported` | The account turned out to be a netting account, found on its first copy. Copy trading copies between hedging accounts only.                  |
| `internal_error`        | Something failed on fxapis's side, not at the broker; the order may or may not have been sent. Check the follower's positions.               |
| `follower_changed`      | The follower's position was changed outside copying (closed in part by hand), so its link now follows what it actually holds.                |

## Events and alerts [#events-and-alerts]

Six webhook events (see [Webhooks](/guides/webhooks)):

* `copy.executed` — a copy reached a follower's broker;
* `copy.skipped` — a copy was not sent;
* `copy.failed` — a copy was sent and refused, or its result is unknown;
* `copy.subscription_stopped` — a subscription stopped: a risk limit
  (`risk_limit:<period>:<metric>`, with `:unverified` when the deposits and
  withdrawals could not be confirmed), a follower that turned out to be a
  netting account (`netting_not_supported`), its creator losing access — a
  member removed or an API key revoked (`creator_revoked`) — or its deletion
  with its positions closed (`deleted`);
* `copy.drift_corrected` — the drift check put a follower right;
* `copy.master_disconnected` — a strategy's master stopped answering.

Two console alerts (the bell), on the follower's account: **`copy_stopped`**
while any of its subscriptions is stopped (not from a deletion), resolved on
reset; and **`copy_failing`**, opened when three executions in a row fail,
resolved by the next success.

## Limits and billing [#limits-and-billing]

Per workspace: 50 strategies, 500 followers per strategy, 5 strategies per
follower, and 200 copy legs a second (beyond which they queue rather than
fail).

Followers beyond a plan's included count are billed **by the day** — a
follower counts for a day if its subscription was `active` or `close_only`
at any point that day:

| Plan                | Included followers                                                  | Price per extra follower |
| ------------------- | ------------------------------------------------------------------- | ------------------------ |
| Trial (of any plan) | the plan's included count, at least 3 (capped — not sold beyond it) | —                        |
| Production          | 0                                                                   | $8/month                 |
| Business            | 25                                                                  | $6/month                 |
| Enterprise          | by agreement                                                        | by agreement             |

Masters are billed as ordinary accounts, plus whatever the plan charges for
`always_on` — a strategy's master must be always-on.

## Not in v1 [#not-in-v1]

* copying pending orders as pending orders (a pending order is copied once
  it fills, as a market open);
* a marketplace, performance fees, or followers in another workspace;
* portfolios (blends of several strategies with weights);
* news, trading-hours and equity-curve filters;
* expression-based sizing;
* netting accounts, as both masters and followers.

## Appendix: build your own copier [#appendix-build-your-own-copier]

If you would rather own the copying rules — your own sizing formula, your own
risk logic, or copying between your own workspaces — build it from the same
parts managed copy trading is built on: the master's broker deals and
positions, [multi-account orders](/guides/multi-account-orders) to fan an
entry out in one call, and closes and stop changes per account. The rest of
this guide is a worked example.

<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-1]

```
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)
```

This copier **polls**, which is the simplest to run. To react within seconds
instead, subscribe a [webhook](/guides/webhooks) to `position.opened` and
`position.closed` on the master and run the same logic per event.

### 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): the
  multi-account order brings every account online and waits up to 60 seconds for them, so one
  offline follower delays the whole fan-out, and is skipped only if it is still not online by then. `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. Each account's `balance` and `equity` are on
`GET /v1/accounts/{id}/figures` (read from its terminal while it is online), so
the ratio can follow them; or it comes from your own records — the balance each
follower funded with, an allocation they chose, or a fixed multiplier. The
program below takes a fixed multiplier per follower.

```
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. A version built on the
official SDKs, in Python and TypeScript, is in
[fxapis-examples](https://github.com/FXapis/fxapis-examples)
([how to run it](https://fxapis.com/blog/mt5-api-examples-python-node-curl)). 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):
    # The order names the follower's position; until it does, a new position carries the order's own ticket.
    if order["state"] in ("filled", "partially_filled") and (order.get("brokerPositionId") or order.get("brokerOrderId")):
        db.execute("insert or ignore into links values (?, ?, ?, ?)",
                   (master_position, follower, order.get("brokerPositionId") or order["brokerOrderId"],
                    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 position ticket — the order's `brokerPositionId` (or, until it
  is known, its `brokerOrderId`, the ticket a new position carries); closes and stop changes use
  that link. On a netting follower that already holds the symbol, the order adds to that position
  instead, which only `brokerPositionId` names, so copy into hedging accounts.
* **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.
* **Polling or webhooks.** The copier below polls; [webhooks](/guides/webhooks) for
  `position.opened`/`position.closed` remove the polling loop, but position events still come
  from the \~15 s position sync.
* **Balances move.** A multiplier taken from balances goes stale as they change; refresh it from
  `GET /v1/accounts/{id}/figures`, 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.
* [Webhooks](/guides/webhooks) — the `copy.*` events.
