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/statusanswerspaused: true: you can read, pause and delete strategies and subscriptions and change their settings, but making a strategy, subscribing, resuming and reset answer503 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.
| 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_figuresrather 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 withtoo_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_demandfollower that is offline when the master opens is started, and the open is still copied if the follower comes online beforemaxDelaySecondsruns out; otherwise it is skipped withfollower_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 withcopyExistingOnStartand 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 acopy.drift_correctedevent.
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.
| 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
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:unverifiedwhen 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:
| Plan | Included followers | Price per extra follower |
|---|---|---|
| Trial | 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
- 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 byreconcile, 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_ontoo, 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_onis 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:
| 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
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 stepRound 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:
- A record of handled deals, keyed by
brokerDealId, written after each deal is handled. - 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}.
- the entry fan-out:
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"""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, itsbrokerOrderId, 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 onlybrokerPositionIdnames, so copy into hedging accounts. - Unresolved legs are never resent. They wait in
pendinguntil their order leavesunknown, 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_FOUNDon 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.closedremove 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.
EURUSDon one may beEURUSD.aorEURUSDmon another. Add a per-follower symbol map if your followers use different brokers. - Netting reversals and close-by. Deals with
entryinoutorout_byare 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
indeal and is copied as a market entry. - MT5 only. fxapis does not connect MT4 accounts.
Related
- One order across many accounts — barrier policies and results in depth.
- Idempotent MT5 orders — why the keys are shaped like this.
- Monitoring trader accounts — reading deals and positions for rule checks.
- Migrating from MetaApi — if you are coming from CopyFactory.
- Webhooks — the
copy.*events.
Place one order across many MT5 accounts
Send the same MetaTrader 5 trade to many accounts in one API call — up to 1,000 accounts, within your plan's limit: per-account volumes, barrier policies for accounts that are not online, scheduled execution with executeAt and expiresAt, per-account results, cancelling, and dispatch spread.
Idempotent MT5 orders: never double-fill on retry
Why retrying a MetaTrader 5 order can open a second position, and how the fxapis Idempotency-Key prevents it: 24-hour keys, replays, in-flight and reuse conflicts, the unknown outcome, and key patterns for signals, copiers and queues.