Build an MT5 trade copier with the API
Copy trades from a master MetaTrader 5 account to many follower accounts over HTTP: detect the master's deals by polling, fan out with multi-account orders, size by balance ratio, mirror closes and stop-loss / take-profit changes, and stay idempotent per master deal.
A trade copier watches one master account and repeats what happens there on a set of follower accounts: an entry becomes an entry on every follower, sized for each; a close becomes a close; a moved stop becomes a moved stop.
fxapis has the parts: the master's broker deals and positions to detect what happened, multi-account orders to fan an entry out in one call, and closes and stop changes per account. This guide puts them together into a working copier, and is plain about its limits.
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)There are no event webhooks yet, so the copier polls. That sets its latency: a master trade is seen on the next poll after it happens, and copied a moment later. See Limitations.
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), and a multi-account order may skip an account that is not online when it is created.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. fxapis does not report account balance or equity, so the ratio comes from your own records — the balance each follower funded with, an allocation they chose, or a fixed multiplier — and is kept up to date by your application.
follower volume = master deal volume × multiplier, rounded DOWN to the volume 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. 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):
if order["state"] in ("filled", "partially_filled") and order.get("brokerPositionId"):
db.execute("insert or ignore into links values (?, ?, ?, ?)",
(master_position, follower, order["brokerPositionId"], order.get("filledVolume") or volume))
elif order["state"] == "unknown":
db.execute("insert or ignore into pending values (?, ?, ?, ?)",
(order["id"], master_position, follower, volume))
def copy_entry(deal, master_positions):
weights = {f: v for f, v in ((f, scaled(deal["volume"], m)) for f, m in FOLLOWERS.items()) if v}
if not weights:
return
source = master_positions.get(deal["brokerPositionId"], {})
body = {
"accountIds": list(weights),
"symbol": deal["symbol"],
"side": deal["dealType"],
"volume": next(iter(weights.values())),
"weights": weights,
"barrierPolicy": "release-ready",
"clientWaveId": f"copy:{deal['brokerDealId']}",
}
for level in ("stopLoss", "takeProfit"):
if source.get(level):
body[level] = source[level]
wave = place("/v1/execution-waves", body, key=f"copy:open:{deal['brokerDealId']}")
while wave["state"] not in ("settled", "cancelled", "abandoned"):
time.sleep(0.5)
wave = api("GET", f"/v1/execution-waves/{wave['id']}")
print(f"copied deal {deal['brokerDealId']}: {wave['summary']} spread {wave['dispatchSpreadMs']} ms")
for leg in wave["legs"]:
if leg["orderId"] and leg["state"] in ("filled", "unresolved"):
order = api("GET", f"/v1/orders/{leg['orderId']}")
link(order, deal["brokerPositionId"], leg["accountId"], leg["volume"])
elif leg["state"] in ("rejected", "skipped"):
print(f" {leg['accountId']} not copied: {leg['state']} ({leg['stateDetail']})")
def copy_exit(deal, master_positions):
master_position = deal["brokerPositionId"]
still_open = master_positions.get(master_position)
links = db.execute("select follower, follower_position, volume from links where master_position = ?",
(master_position,)).fetchall()
for follower, ticket, volume in links:
body = {}
if still_open and still_open.get("volume"):
# Partial close: close the same fraction of the follower's position.
before = Decimal(still_open["volume"]) + Decimal(deal["volume"])
part = scaled(volume, Decimal(deal["volume"]) / before)
if part is None:
continue # this follower's share is below the minimum
if Decimal(part) < Decimal(volume):
body = {"volume": part}
try:
order = place(f"/v1/accounts/{follower}/positions/{ticket}/close", body,
key=f"copy:close:{deal['brokerDealId']}:{follower}")
except ApiError as error:
if error.code == "POSITION_NOT_FOUND": # already closed (a stop hit, or closed by hand)
db.execute("delete from links where master_position = ? and follower = ?", (master_position, follower))
continue
if error.code == "ORDER_UNRESOLVED": # never resend: it is being confirmed
print(f" close on {follower} unresolved: order {error.details[0]['orderId']}")
continue
print(f" close on {follower} failed: {error}")
continue
if body:
remaining = Decimal(volume) - Decimal(order.get("filledVolume") or body["volume"])
db.execute("update links set volume = ? where master_position = ? and follower = ?",
(str(remaining), master_position, follower))
else:
db.execute("delete from links where master_position = ? and follower = ?", (master_position, follower))
def mirror_stops(master_positions):
for ticket, position in master_positions.items():
levels = (position.get("stopLoss"), position.get("takeProfit"))
row = db.execute("select stop_loss, take_profit from stops where master_position = ?", (ticket,)).fetchone()
if row is not None and tuple(row) != levels:
for follower, follower_position in db.execute(
"select follower, follower_position from links where master_position = ?", (ticket,)).fetchall():
try:
# null removes a level; the same absolute prices as the master
api("POST", f"/v1/accounts/{follower}/positions/{follower_position}/modify",
json={"stopLoss": levels[0], "takeProfit": levels[1]})
except ApiError as error:
print(f" stops on {follower} not moved: {error}")
db.execute("insert or replace into stops values (?, ?, ?)", (ticket, *levels))
def resolve_pending():
for order_id, master_position, follower, volume in db.execute("select * from pending").fetchall():
order = api("GET", f"/v1/orders/{order_id}")
if order["state"] != "unknown":
db.execute("delete from pending where order_id = ?", (order_id,))
link(order, master_position, follower, volume)
def tick():
api("POST", f"/v1/accounts/{MASTER}/reconcile") # pull the master's latest deals and positions
master_positions = {p["brokerPositionId"]: p for p in api("GET", f"/v1/accounts/{MASTER}/positions")}
for deal in new_deals():
if deal["entry"] == "in":
copy_entry(deal, master_positions)
elif deal["entry"] == "out":
copy_exit(deal, master_positions)
else:
print(f"deal {deal['brokerDealId']} has entry {deal['entry']}: not copied, review it")
db.execute("insert or ignore into handled_deals values (?)", (deal["brokerDealId"],))
db.execute("update cursor set since = ? where id = 1", (deal["dealtAt"],))
db.commit()
mirror_stops(master_positions)
resolve_pending()
db.commit()
if __name__ == "__main__":
while True:
try:
tick()
except Exception as error: # keep copying; the state in SQLite makes a retry safe
print(f"tick failed: {error}")
time.sleep(POLL_SECONDS)What to notice in it:
- Entries fan out in one call. The master's deal becomes one multi-account order with a
volume per follower in
weights, carrying the master's stop loss and take profit. - Each follower position is linked to its master position. After the fan-out, each filled
leg's order gives the follower's
brokerPositionId; closes and stop changes use that link. - Unresolved legs are never resent. They wait in
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.
- No event webhooks yet. There is no push notification of a master's trade; the copier must
poll.
reconcilepulls deals from the broker, and only while the master is online. - Account balance is not reported. Size from your own records, as above.
- Symbol names differ between brokers.
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.
Place one order across many MT5 accounts
Send the same MetaTrader 5 trade to many accounts in one API call — up to 500 on Enterprise (Starter 10, Scale 100): 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.