MetaTrader®, MT4® and MT5® are trademarks of MetaQuotes Ltd. This is an independent service and is not affiliated with, authorized by, or endorsed by MetaQuotes.

Development · 13 min read · 2026-06-09

How to Build a Copy Trading Platform with MT5 REST API

By MetaTrader API Editorial Team

How to Build a Copy Trading Platform with MT5 REST API

Build a copy trading platform on MT5 with a REST API and WebSocket: master detection, risk engine, follower execution and scaling, with real Python code.

Copy trading is one of the fastest-growing fintech verticals in the world. Platforms like eToro, ZuluTrade, and NAGA have proved there is enormous demand: traders want to follow experts, and profitable traders want to monetize their edge. The question is — how do you build the infrastructure behind it yourself?

The answer is not a white-label solution that locks you into someone else's fee structure, subscriber data, and feature roadmap. The answer is a REST API + WebSocket bridge between your master account and your follower accounts, running on your own server, under your own brand.

This guide walks you through the complete technical architecture — from detecting a master trade in real time to executing it proportionally across dozens of follower accounts — using MetaTrader API and Python.

No Expert Advisor for you to run, no MQL5, and nothing to install on your side.

What You Are Actually Building

A copy trading platform has three core components:

  1. The Signal Source — a master MT5 account whose trades you detect in real time

  2. The Copy Engine — your server that applies risk rules and replicates trades

  3. The Follower Layer — one or more subscriber MT5 accounts that receive executions

MetaTrader API provides the bridge between all three. Your server is the only custom piece you write.

Prerequisites

Multi-account copy trading system architecture mapping master session to follower accounts via REST API

Part 1 — Connecting the Master and Follower Accounts

Every account is connected once. From a backend that onboards users, use POST /v1/seats with your management key: it returns the account id (a UUID) that identifies that account as ?id= in every later call. (/ConnectEx?user=&password=&server=&platform=MT5 does the same for code written to the classic MetaTrader REST conventions, but it puts the broker password in the URL.) You end up with one account id per account — master and each follower.

import os, time, requests

API = "https://api.metatraderapi.net"
WS = "wss://api.metatraderapi.net"
API_KEY = os.environ["MTAPI_KEY"]        # API key, not account-scoped: it can address all your accounts via ?id=
MGMT_KEY = os.environ["MTAPI_MGMT_KEY"]  # management key: creates and removes seats

api = requests.Session()
api.headers["x-api-key"] = API_KEY
mgmt = requests.Session()
mgmt.headers["Authorization"] = f"Bearer {MGMT_KEY}"

def connect(mt_login: int, mt_password: str, mt_server: str, label: str) -> str:
    """Connect an MT5 account and wait until it is ACTIVE. Returns its account id."""
    r = mgmt.post(f"{API}/v1/seats", json={
        "label": label,
        "platform": "MT5",
        "brokerServer": mt_server,     # exactly as the broker spells it
        "brokerLogin": str(mt_login),
        "brokerPassword": mt_password, # the main (trading) password
    }, timeout=75)
    r.raise_for_status()
    account_id = r.json()["seat"]["id"]

    for _ in range(60):                # usually ACTIVE within 1-2 minutes
        seat = mgmt.get(f"{API}/v1/seats/{account_id}", timeout=75).json()["seat"]
        if seat["status"] == "ACTIVE":
            print(f"[{label}] connected: {account_id}")
            return account_id
        if seat["status"] == "ERROR":
            # the reason is reported by /ConnectState (the seat object has no statusReason)
            state = mgmt.get(f"{API}/ConnectState", params={"id": account_id}, timeout=75).json()
            raise RuntimeError(f"[{label}] connection failed: {state.get('statusReason')}")
        time.sleep(3)
    raise TimeoutError(f"[{label}] not ACTIVE after 3 minutes")

# Connect the master account
MASTER_ID = connect(12345678, "master_pass", "YourBroker-Live", "MASTER")

# Connect follower accounts
FOLLOWERS = {
    "follower_alice": connect(12345001, "alice_pass", "YourBroker-Live", "ALICE"),
    "follower_bob": connect(12345002, "bob_pass", "AnotherBroker-Real", "BOB"),
}

Pro tip: Store the account ids in your database. They are permanent: dropped broker connections are restored automatically, so restarting your own server needs no reconnect at all. If /ConnectState?id= ever reports DISCONNECTED for an account, call /Reconnect?id= instead of creating a new seat.

Part 2 — Building the Real-Time Detection Engine

Once the master account is connected, you need to see every trade it opens, modifies, or closes — in real time. One WebSocket per account, wss://api.metatraderapi.net/v1/stream?id=<account id> with the x-api-key header, carries all of it. No subscription call is needed for trade events:

  1. TRADE frames — one per executed deal: opens (entry: "IN"), full and partial closes (entry: "OUT"), stop-loss and take-profit hits

  2. POSITIONS frames — the full list of open positions, with sl and tp, whenever it changes (the first one after connecting is a snapshot of the current state)

Every frame has the shape {"type": "...", "data": ...}.

import json, threading
import websocket   # pip install websocket-client

def start_master_listener(account_id: str, on_trade, on_positions):
    """
    Open the account's stream. Calls on_trade(deal) for every executed deal and
    on_positions(list) whenever the open positions change. Reconnects on its own.
    """
    def on_message(ws, raw):
        msg = json.loads(raw)
        if msg["type"] == "TRADE":
            on_trade(msg["data"])
        elif msg["type"] == "POSITIONS":
            on_positions(msg["data"])
        elif msg["type"] == "HEARTBEAT" and msg["data"]["connection"] != "connected":
            print("[MASTER WS] stream link:", msg["data"]["connection"])

    ws = websocket.WebSocketApp(
        f"{WS}/v1/stream?id={account_id}",
        header={"x-api-key": API_KEY},
        on_message=on_message,
        on_error=lambda ws, err: print(f"[MASTER WS] error: {err}"),
        on_close=lambda ws, code, reason: print(f"[MASTER WS] closed {code} {reason}"),
    )
    # Background thread keeps the main process free; reconnect=5 retries after 5 s
    threading.Thread(target=ws.run_forever, kwargs={"reconnect": 5}, daemon=True).start()
    print("[MASTER WS] listener started")

When your master trader opens a EURUSD position, the socket delivers a frame like this:

{
  "type": "TRADE",
  "data": {
    "deal": 51230987, "order": 294871023, "position": 294871023, "ticket": 51230987,
    "symbol": "EURUSD", "type": "BUY", "entry": "IN", "volume": 1.0, "price": 1.0842,
    "commission": -3.5, "swap": 0.0, "profit": 0.0, "fee": 0.0,
    "magic": 0, "expertId": 0, "comment": "breakout strategy",
    "time": "2026-10-12T12:15:02Z", "timeUtc": "2026-10-12T09:15:02Z"
  }
}

entry tells you what happened: "IN" opened a position, "OUT" closed all or part of one. position is the id of the position the deal belongs to — that is the key you map follower positions to. Stop-loss and take-profit changes are not deals: you see them in the next POSITIONS frame. (The engine below assumes hedging accounts, where every open is its own position.)

Part 3 — The Risk Engine

This is the most important component. A naive copy engine that blindly mirrors every trade at 1:1 volume will blow up followers with different account sizes. A professional copy trading platform always applies a risk calculation layer.

The two most common approaches are:

A) Equity-Ratio Lot Sizing

The follower's lot size is proportional to their account equity relative to the master:

follower_lots = master_lots × (follower_equity / master_equity)

B) Fixed Multiplier

Each follower has a personal multiplier (e.g., 0.5× or 2×) configured at sign-up.

Both require knowing the follower's current equity, which you fetch with /AccountSummary:

def get_account_summary(account_id: str) -> dict:
    """Live balance, equity, margin and free margin of one account."""
    r = api.get(f"{API}/AccountSummary", params={"id": account_id}, timeout=75)
    r.raise_for_status()
    return r.json()   # balance, equity, margin, freeMargin, marginLevel, currency, ...

def calculate_follower_lots(
    master_lots: float,
    master_equity: float,
    follower_equity: float,
    min_lots: float = 0.01,
    max_lots: float = 10.0,
) -> float:
    """Equity-ratio proportional lot sizing with floor/ceiling enforcement."""
    if master_equity <= 0:
        return min_lots
    raw = master_lots * (follower_equity / master_equity)
    sized = round(raw, 2)   # round to the symbol's volumeStep in production (/SymbolParams)
    return max(min_lots, min(sized, max_lots))

def should_replicate(summary: dict, min_free_margin: float = 100.0) -> bool:
    """
    Safety gate: skip replication if the follower account is underfunded
    or the margin level is dangerously low.
    """
    if summary["freeMargin"] < min_free_margin:
        print(f"[RISK] Skipping — free margin too low: {summary['freeMargin']}")
        return False
    # marginLevel is 0 while no margin is in use, so only check it when there is some
    if summary["margin"] > 0 and summary["marginLevel"] < 150:   # below 150% = danger zone
        print(f"[RISK] Skipping — margin level critical: {summary['marginLevel']}%")
        return False
    return True

Part 4 — Executing Trades on Follower Accounts

With the risk engine in place, you can now execute the replicated trade on each follower account with /OrderSend. Two details make it production-safe:

# {master_position: {follower_name: {"ticket", "ratio", "master_volume", "sl", "tp"}}}
TICKET_MAP: dict = {}
# Latest master positions from the POSITIONS frames: {ticket: position}
MASTER_POSITIONS: dict = {}

def replicate_open(deal: dict, master_equity: float):
    """Replicate a master opening deal to every follower, risk pipeline first."""
    for name, follower_id in FOLLOWERS.items():
        try:
            summary = get_account_summary(follower_id)
            if not should_replicate(summary):
                continue   # safety gate failed — skip this follower

            lots = calculate_follower_lots(deal["volume"], master_equity, summary["equity"])

            r = api.get(f"{API}/OrderSend", params={
                "id": follower_id,
                "symbol": deal["symbol"],   # map symbol names per broker, see below
                "operation": "Buy" if deal["type"] == "BUY" else "Sell",
                "volume": lots,
                "comment": f"copy#{deal['position']}",
                "clientOrderId": f"copy-{deal['deal']}-{name}",
            }, timeout=75)
            r.raise_for_status()
            order = r.json()
            if order.get("state") != 10009:
                print(f"[{name}] broker refused the copy, retcode {order.get('state')}")
                continue

            # IMPORTANT: store master position → follower ticket.
            # You need it to replicate closes and SL/TP changes later.
            TICKET_MAP.setdefault(deal["position"], {})[name] = {
                "ticket": order["ticket"], "ratio": lots / deal["volume"],
                "master_volume": deal["volume"], "sl": 0.0, "tp": 0.0,
            }
            print(f"[{name}] copied open: {lots} lots → ticket {order['ticket']}")
        except Exception as e:
            print(f"[{name}] replication failed: {e}")

    # The master's SL/TP may already be known from a POSITIONS frame
    if deal["position"] in MASTER_POSITIONS:
        sync_stops(MASTER_POSITIONS[deal["position"]])

MT4 followers: the same code works. /OrderSend, /OrderClose and /OrderModify take the same parameters on MT4 and MT5; on MT4 a refused order reports the MT4 error number in state (success is still 10009).

Part 5 — Handling Closes and Modifications

A complete copy trading platform must also replicate when the master closes or modifies a trade. Closes arrive as TRADE frames with entry: "OUT"; SL/TP changes arrive in POSITIONS frames.

Closing a Follower Trade

When the master closes position 294871023, you look it up in your ticket map and close the follower's corresponding ticket with /OrderClose. Without lots it closes the whole position; with lots it closes part of it:

def replicate_close(deal: dict):
    """Close (or partially close) follower positions when the master closes."""
    links = TICKET_MAP.get(deal["position"], {})
    for name, link in list(links.items()):
        remaining = round(link["master_volume"] - deal["volume"], 2)
        params = {"id": FOLLOWERS[name], "ticket": link["ticket"]}
        if remaining > 0:   # partial close on the master
            params["lots"] = round(deal["volume"] * link["ratio"], 2)
        try:
            r = api.get(f"{API}/OrderClose", params=params, timeout=75)
            r.raise_for_status()
            result = r.json()
            if result.get("state") != 10009:
                print(f"[{name}] close refused, retcode {result.get('state')}")
                continue
            print(f"[{name}] closed follower ticket {link['ticket']}")
            if remaining > 0:
                link["master_volume"] = remaining
            else:
                del links[name]
        except Exception as e:
            print(f"[{name}] close failed: {e}")

Copying Stop-Loss and Take-Profit Changes

def sync_stops(position: dict):
    """Copy the master's sl/tp to every follower position that differs."""
    for name, link in TICKET_MAP.get(position["ticket"], {}).items():
        if (link["sl"], link["tp"]) == (position["sl"], position["tp"]):
            continue
        r = api.get(f"{API}/OrderModify", params={
            "id": FOLLOWERS[name],
            "ticket": link["ticket"],
            "stoploss": position["sl"],     # absolute prices; 0 = none
            "takeprofit": position["tp"],
        }, timeout=75)
        result = r.json() if r.ok else {}
        if result.get("state") == 10009:
            link["sl"], link["tp"] = position["sl"], position["tp"]
            print(f"[{name}] modified ticket {link['ticket']}: sl={position['sl']} tp={position['tp']}")
        else:
            print(f"[{name}] modify refused: {r.status_code} {result.get('state')}")

The Master Event Router

Bring all handlers together in one clean dispatcher:

def on_master_trade(deal: dict):
    """Route each master deal to the right handler."""
    if not deal.get("symbol"):
        return   # defensive: TRADE frames are trade deals, always with a symbol
    if deal["entry"] == "IN":
        master_equity = get_account_summary(MASTER_ID)["equity"]
        replicate_open(deal, master_equity)
    elif deal["entry"] in ("OUT", "INOUT"):
        replicate_close(deal)

def on_master_positions(positions: list):
    """Keep the latest master positions and copy any SL/TP change."""
    MASTER_POSITIONS.clear()
    MASTER_POSITIONS.update({p["ticket"]: p for p in positions})
    for p in positions:
        sync_stops(p)

# Start the WebSocket listener and keep the process alive
start_master_listener(MASTER_ID, on_master_trade, on_master_positions)
while True:
    time.sleep(60)

Your copy trading engine is now fully operational. Every trade the master opens, closes, or modifies is replicated in near real time across every registered follower account — with proportional sizing and margin safety gates.

Part 6 — Monitoring Open Positions Across All Accounts

Your platform needs a live view of what is open across all accounts — for your dashboard, for your followers, and for your own risk monitoring. /OpenedOrders gives you that (open positions and pending orders, as order objects):

def get_all_open_positions() -> dict:
    """Fetch open positions for master and all followers."""
    all_positions = {}
    for name, account_id in {"master": MASTER_ID, **FOLLOWERS}.items():
        r = api.get(f"{API}/OpenedOrders", params={"id": account_id}, timeout=75)
        r.raise_for_status()
        orders = r.json()
        all_positions[name] = orders
        for o in orders:
            print(f"  [{name}] {o['symbol']} type={o['orderType']} "
                  f"| {o['lots']} lots | P/L: {o['profit']:.2f}")
    return all_positions

Poll this every 30–60 seconds to keep your dashboard live. Without polling, read the POSITIONS frames on each account's stream instead: they carry profit for every open position and arrive whenever the positions change.

Build vs. Buy: DIY API vs. White-Label

Many developers start by evaluating whether to build from scratch or use a white-label copy trading solution. Here is the honest comparison:

Feature White-Label Provider MetaTrader API DIY Build
Time to first working demo Days (theirs, not yours) Under 2 hours with this guide
Monthly SaaS cost €500–€3,000+/mo MetaTrader API seats only ($12–$14 per connected account per month)
Revenue share to provider 10–30% of subscription revenue 0%: you keep everything
Full source code ownership No Yes
Custom risk rules per follower Limited Unlimited
Cross-broker copy (MT4 ↔ MT5) Rare Native
Own subscriber database Locked in their CRM Your database
Custom branding & domain Partial Complete
Regulatory compliance Provider handles some You own it
Scaling Limited by the provider Up to 50 seats per customer, more on request

The white-label makes sense if you need to launch in 48 hours with no developers. If you are reading a technical guide like this, you are building something — and the DIY path gives you complete control of a product that is actually yours.

Scaling Your Copy Trading Platform

Once you have the core engine working, these are the real production concerns:

Multiple Master Accounts

You can run multiple master listeners simultaneously — each in its own background thread with its own WebSocket connection. Your server becomes a fan-out router: N masters → M followers each.

Account Persistence

Store every account id in a database (PostgreSQL, Redis). An account id never changes and the broker connection behind it is restored automatically, so a restart of your server only means reopening the WebSocket streams. Check /ConnectState (without id, it lists all your accounts) at startup and call /Reconnect?id= for any account that reports DISCONNECTED. This makes restarts invisible to your users.

Symbol Name Mapping

Different brokers use different symbol names (EURUSD vs EURUSD.m vs EURUSDpro). Build a configurable mapping table per follower account at registration time. Use /SymbolList to query what symbols are available on any connected account before executing:

def get_symbol_list(account_id: str) -> list:
    r = api.get(f"{API}/SymbolList", params={"id": account_id}, timeout=75)
    r.raise_for_status()
    return r.json()

Follower Onboarding Flow

When a new subscriber signs up to your platform:

  1. Collect their MT5 login, main password, and exact server name
  2. Call POST /v1/seats (the connect() function above) to connect the account
  3. Store the returned account id in your database
  4. Call /AccountSummary to verify the connection and record their starting balance
  5. Show them their account stats on your dashboard

The flow is a handful of API calls; most of the time is the broker login, which usually completes within one to two minutes.

Trade History and Performance Stats

Use /OrderHistory to pull closed trades for any account and compute performance metrics — win rate, profit factor, max drawdown — to display on leaderboards or subscriber-facing profile pages:

def get_trade_history(account_id: str, from_date: str, to_date: str) -> list:
    """Closed trades in the range. /OrderHistory answers {"orders": [...], ...}."""
    r = api.get(f"{API}/OrderHistory", params={
        "id": account_id,
        "from": from_date,   # ISO-8601 or unix seconds, UTC, e.g. "2026-01-01T00:00:00Z"
        "to": to_date,       # e.g. "2026-06-09T23:59:59Z"
    }, timeout=75)
    r.raise_for_status()
    return r.json()["orders"]

What to Build Next

Once your copy engine is running, here is a natural roadmap:

  1. Subscriber web dashboard — live open positions, equity chart, copy status for each follower

  2. Payment integration — gate API access to paid subscribers via Stripe webhooks

  3. Telegram / Discord bot — notify subscribers when a master trade opens

  4. Performance leaderboard — rank master traders by return, drawdown, and consistency

  5. Risk profile per follower — let each subscriber choose conservative / moderate / aggressive lot sizing

  6. Multi-master support — let subscribers follow multiple master accounts simultaneously

For the broker and CRM integration side of scaling this platform, see:

How Brokers Automate Account Management with MetaTrader API →

For prop firm specific use cases — where you need to enforce drawdown rules on follower accounts in real time — see:

Build Prop Firm Rule Enforcement on MetaTrader Accounts →

Frequently asked questions

Can I build a copy trading platform without MQL5?

Yes. Everything in this guide runs in standard Python over HTTP and WebSocket. You do not need to write, compile, or install a single line of MQL5. MetaTrader API handles the connection to the broker on its side; there is nothing for you to install.

How does copy trading work technically with the MT5 REST API?

Your server connects the master and follower accounts with POST /v1/seats (or /ConnectEx), then opens the master's WebSocket at wss://api.metatraderapi.net/v1/stream?id=<account id>. TRADE frames arrive for every executed deal without any subscription. On each opening deal, your risk engine calls /AccountSummary on the follower, calculates proportional lots, then calls /OrderSend with a clientOrderId and checks for state == 10009. Full endpoint details are in the API documentation.

Can followers be at different brokers?

Yes. Each follower connects their own MT4 or MT5 account independently. The broker is irrelevant — only the login, password, and server name matter. Build a symbol-name mapping table if followers have different broker symbol naming conventions.

What is the latency from master trade to follower execution?

The TRADE frame on the master's /v1/stream WebSocket arrives shortly after the deal is executed: new deals are picked up about once per second, so allow roughly one to two seconds from the master's fill until your follower orders go out, plus each follower broker's own execution time. Sending the follower orders in parallel keeps that from growing with the number of followers. MetaTrader API runs its API servers in the EU (Finland and Germany) — see Low-Latency MetaTrader Infrastructure → for infrastructure design details.

Does this work for MT4 as well as MT5?

Yes. The same endpoints (/OrderSend, /OrderClose, /OrderModify) and the same base URL work for MT4 and MT5, so the architecture is identical. On MT4 a refused order reports the MT4 error number in state; success is still 10009.

External resources:

Create a MetaTrader API account to build your own broker-agnostic copy trading platform

Create Your Account Today

The complete copy trading engine in this guide is under 200 lines of Python. You can have a working demo connecting a master and follower account in under two hours. Each connected account is a paid seat ($14 per month); there is no free trial, so test with broker demo accounts.

→ Create account at app.metatraderapi.net

→ Read the full API documentation at app.metatraderapi.net/docs

→ Browse all developer guides on the MetaTrader API Blog

Your copy trading platform. Your subscribers. Your revenue.

MetaTrader®, MT4® and MT5® are registered trademarks of MetaQuotes Ltd. MetaTrader API is an independent service and is not affiliated with, authorized by, or endorsed by MetaQuotes.

Ready to integrate the MetaTrader API?

REST, WebSocket and gRPC for MT4 and MT5. Pay per connected account.

Create account