Docs

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 your side. Or build your own copier from multi-account orders.

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.

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

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, on the Trading engine row.

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.

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.

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"}'
{
  "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).

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" } }
  }'
{
  "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.

curl -s "https://api.fxapis.com/v1/copy/executions?strategyId=a1b2c3d4-0000-4000-8000-000000000001" \
  -H "Authorization: Bearer $FXAPIS_KEY"
{
  "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

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.

SettingValuesDefault
sizing.modebalance_ratio, equity_ratio, multiplier, fixed_lots, risk_percentbalance_ratio
sizing.multiplier> 0, ≤ 100. Scales every ratio mode1
sizing.lotsfor fixed_lots—
sizing.riskPercent0.01–10, for risk_percent. A master trade with no stop loss is skipped (no_stop_loss)—
reverseboolean — buy ↔ sell, and stop loss ↔ take profitfalse
copySlTpoff, on_open, alwaysalways
symbols.allow / symbols.blocklists of instrument names. An empty allow list means every symbol[] / []
symbols.mapmaster symbol (or instrument) → this follower's symbol{}
limits.maxLotsper copied tradenone
limits.maxOpenPositionson the follower, from this subscriptionnone
limits.minLotsskip or use_min, when the scaled size is below the broker's minimumskip
late.maxDelaySeconds1–600. An open seen later than this is skipped30
late.maxDeviationPointsnull (off), or a number of points the follower's price may have moved from the master's fill before an open is skippednull
late.copyExistingOnStartalso open the master's existing positions when subscribing or resuming, within the deviation limitfalse
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

  • 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

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:

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

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.

ReasonWhat to do
too_lateThe change was seen too long ago to copy. Raise late.maxDelaySeconds, or accept the skip.
price_movedThe follower's price moved further than late.maxDeviationPoints allows. Raise it, or set it to null.
follower_offlineThe follower could not be brought online in time. Keep it always_on, or accept the skip.
below_min_volumeThe sized volume was below the follower's minimum and limits.minLots is skip. Set it to use_min, or raise sizing.multiplier.
no_stop_lossrisk_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_figuresThe follower's (or master's) balance or equity could not be read in the last 5 minutes. Make sure the account stays online.
symbol_unmappedThe master's symbol has no match on the follower's broker. Add it to symbols.map.
symbol_blockedThe symbol is blocked, or not on the allow list, in this subscription's settings. Change symbols.allow / symbols.block.
limit_exceededlimits.maxLots, limits.maxOpenPositions, or the creating key's own limits, stopped it. Raise the limit, or accept the skip.
not_enough_marginThe follower did not have the margin for it. Fund the account, or size it smaller.
market_closedThe market was closed on the follower's broker.
feature_not_includedThe workspace's plan does not include copy trading. Upgrade.
quota_exceededThe workspace's plan quota (orders, environment) was used up.
broker_rejectedThe follower's broker refused it; detail carries its own code.
unresolvedSent, with no answer from the broker yet. Never resent; it settles once the broker's records are read.
netting_not_supportedThe account turned out to be a netting account, found on its first copy. Copy trading copies between hedging accounts only.
internal_errorSomething failed on fxapis's side, not at the broker; the order may or may not have been sent. Check the follower's positions.
follower_changedThe follower's position was changed outside copying (closed in part by hand), so its link now follows what it actually holds.

Events and alerts

Six webhook events (see 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

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:

PlanIncluded followersPrice per extra follower
Trial3 (capped — not sold beyond it)—
Production0$8/month
Business25$6/month
Enterpriseby agreementby 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

  • 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

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 to fan an entry out in one call, and closes and stop changes per account. The rest of this guide is a worked example.

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.

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)

This copier polls, which is the simplest to run. To react within seconds instead, subscribe a webhook to position.opened and position.closed on the master and run the same logic per event.

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.

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

Detecting the master's 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 after a reconcile has brought the record up to date. The fields it uses:

FieldUse
brokerDealIdUnique per deal. The copier's idempotency anchor.
dealTypebuy or sell for trades. Others — balance, commission, credit… — are ignored.
entryin opens exposure, out closes it. inout (a reversal on a netting account) and out_by (close-by) need handling of their own.
brokerPositionIdWhich master position the deal belongs to. Links follower positions to it.
symbol, volumeWhat to copy. volume is a string.
dealtAtThe 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 with what it saw last time.

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

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.

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 (how to run it). It handles one master and any number of followers, copies entries and full or partial closes, mirrors stop changes, and survives restarts.

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

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

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

On this page