DEV Community

Cover image for Inside a Trade Copier: From a MetaTrader Fill to a FIX Order in C#
Sergiy Lutsak
Sergiy Lutsak

Posted on

Inside a Trade Copier: From a MetaTrader Fill to a FIX Order in C#

A trade copier sounds like a weekend project. Watch one account, repeat its trades on another. The first version takes a day. The next two years go into the cases that version gets wrong: the trade that was copied twice after a reconnect, the follower that ended up with 0.07 lots nobody can explain, the restart on a Friday evening that re-opened everything.

I build HFT Forex Copier for a living, so this article is the design I wish someone had handed me at the start. It follows one trade from a MetaTrader master to a FIX follower account and stops at each of the four problems on the way: detection, duplication, translation and recovery. The first two parts of this series covered the FIX side (the message format in part 1, the session layer in part 2), so here the FIX order is simply the output.

The C# below is plain C# 7 with no packages and runs on .NET Framework 4.8 and current .NET.

Problem 1: noticing that the master traded

How quickly you learn about a master trade depends on the platform.

MetaTrader 5 tells you. An Expert Advisor can implement OnTradeTransaction, which the terminal calls when a trade transaction is processed; a transaction of type TRADE_TRANSACTION_DEAL_ADD is a new deal. The MQL5 reference has the details.

MetaTrader 4 does not. MQL4 has tick, timer and chart events but no trade event, so an EA has to poll the order pool from OnTimer and OnTick and compare it with what it saw last time. Polling has a price that is easy to compute: if you poll every T milliseconds and trades arrive at random moments, the average detection delay is T/2 and the worst case is T. Every millisecond of polling period costs half a millisecond of average latency before your code has done anything.

Either way the event then has to leave the terminal, because a serious copier is a separate process. The usual options on one Windows host are a named pipe, a memory-mapped file or a loopback TCP socket. All three are fast enough that the choice is about failure behaviour, not speed: what happens when the reader is down, and how the writer finds out.

The platforms also disagree about what a position is. MT4 is hedging only, every trade is its own ticket. MT5 can be hedging or netting. A FIX account is almost always netting. The consequences for choosing a master are covered in our MT4 vs MT5 for copy trading guide; for the code it means the copier must keep its own record of which follower quantity belongs to which master ticket. We will get to that.

Whatever the transport, what arrives in the copier is an event:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;

public enum MasterEventKind { Open, PartialClose, Close }

public struct MasterEvent
{
    public string Account;        // master account number
    public long Ticket;           // MT4 order ticket or MT5 position id
    public MasterEventKind Kind;
    public int Step;              // 0 for Open, 1..n for successive partial closes, 0 for Close
    public string Symbol;         // as the master broker spells it, e.g. "EURUSD.r"
    public bool Buy;              // direction of the master POSITION
    public decimal Lots;          // lots opened, or lots closed by this event

    // Same event seen twice gives the same key. That is the whole trick.
    public string Key
    {
        get
        {
            string k = Kind == MasterEventKind.Open ? "O" : Kind == MasterEventKind.Close ? "C" : "P";
            return Account + "-" + Ticket.ToString(CultureInfo.InvariantCulture) + "-" + k +
                   Step.ToString(CultureInfo.InvariantCulture);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Key property is the most important line in the whole design. An event that describes the same thing must always produce the same key, no matter how many times or in which session it is observed.

Problem 2: never send it twice

Duplicates are not an edge case. A poller that compares snapshots will report the same new order twice if the comparison state is lost. A reconnect replays recent events. A restart replays all of them, because the terminal still shows the open positions and the copier has just been born.

The defence has two layers. First, remember every key you have acted on, durably. Second, use the key as the FIX ClOrdID, so that even if the first layer fails, the order carries the same identifier and many venues will reject the duplicate instead of filling it. Do not rely on the second layer alone: duplicate ClOrdID handling varies by broker.

Translation comes first, because the ledger records follower quantities.

Problem 3: translating symbols and volumes

The master says EURUSD.r and 0.50. The follower venue wants EUR/USD and 25000. Three conversions hide in there:

  • Symbol. Retail brokers decorate symbols with suffixes (.r, .ecn, m). Strip the suffix, then map the root to the follower's spelling. Anything unmapped must be skipped loudly, never guessed.
  • Lots to units. One lot is 100,000 units for FX pairs, but 100 ounces for gold at most brokers, and index contracts are all over the place. The contract size belongs in configuration per instrument.
  • Sizing and rounding. The follower rarely trades the same size as the master. The simplest rule is a multiplier; the others (fixed lot, balance-proportional, risk-based) are explained in our guide to sizing a copier across different balances. Whatever the rule, the result must be rounded to the follower's quantity step, and rounded down. Rounding up silently adds risk.
public sealed class Instrument
{
    public string FixSymbol;      // how the follower venue spells it
    public decimal ContractSize;  // units per 1.00 lot on the master
    public decimal MinQty;        // follower minimum order quantity, in units
    public decimal QtyStep;       // follower quantity increment, in units
}

public sealed class SymbolMap
{
    readonly Dictionary<string, Instrument> _map = new Dictionary<string, Instrument>(StringComparer.OrdinalIgnoreCase);
    readonly string[] _suffixes;

    public SymbolMap(params string[] masterSuffixes) { _suffixes = masterSuffixes; }

    public void Add(string masterRoot, Instrument instrument) { _map[masterRoot] = instrument; }

    public bool TryResolve(string masterSymbol, out Instrument instrument)
    {
        string root = masterSymbol;
        foreach (string s in _suffixes)
            if (root.EndsWith(s, StringComparison.OrdinalIgnoreCase)) { root = root.Substring(0, root.Length - s.Length); break; }
        return _map.TryGetValue(root, out instrument);
    }
}

public static class Sizing
{
    // Multiplier mode: follower volume = master volume x factor, converted to units and
    // rounded DOWN to the follower's step. Rounding up would add risk nobody asked for.
    public static decimal ToFollowerQty(decimal masterLots, decimal multiplier, Instrument i)
    {
        decimal raw = masterLots * multiplier * i.ContractSize;
        decimal stepped = Math.Floor(raw / i.QtyStep) * i.QtyStep;
        return stepped < i.MinQty ? 0m : stepped;
    }
}
Enter fullscreen mode Exit fullscreen mode

The ledger

Now the memory. An append-only text file is enough, and it has two virtues a database lacks: you can read it with your eyes during an incident, and a half-written last line is the worst that can happen to it.

// Append-only file: one line per processed event. Survives restarts, trivially auditable.
public sealed class CopyLedger
{
    readonly string _path;
    readonly HashSet<string> _done = new HashSet<string>();
    readonly Dictionary<string, decimal> _openQty = new Dictionary<string, decimal>();  // per master ticket

    public CopyLedger(string path)
    {
        _path = path;
        if (!File.Exists(path)) return;
        foreach (string line in File.ReadAllLines(path))
        {
            string[] p = line.Split(';');                       // key;positionKey;signedQty
            _done.Add(p[0]);
            Apply(p[1], decimal.Parse(p[2], CultureInfo.InvariantCulture));
        }
    }

    public bool AlreadyDone(string eventKey) { return _done.Contains(eventKey); }

    public decimal OpenQty(string positionKey)
    {
        decimal q;
        return _openQty.TryGetValue(positionKey, out q) ? q : 0m;
    }

    public void Commit(string eventKey, string positionKey, decimal signedQty)
    {
        File.AppendAllText(_path, eventKey + ";" + positionKey + ";" +
                                  signedQty.ToString(CultureInfo.InvariantCulture) + Environment.NewLine);
        _done.Add(eventKey);
        Apply(positionKey, signedQty);
    }

    void Apply(string positionKey, decimal signedQty)
    {
        _openQty[positionKey] = OpenQty(positionKey) + signedQty;
    }
}
Enter fullscreen mode Exit fullscreen mode

Each line records the event key, the master position it belongs to and the signed follower quantity. Replaying the file on start-up rebuilds both the set of processed keys and the quantity held per master ticket.

The engine

With those pieces the core logic is short:

public sealed class OrderIntent
{
    public string ClOrdId, FixSymbol;
    public bool Buy;
    public decimal Qty;

    public override string ToString()
    {
        return (Buy ? "BUY  " : "SELL ") + Qty.ToString(CultureInfo.InvariantCulture).PadLeft(7) +
               " " + FixSymbol.PadRight(8) + " ClOrdID=" + ClOrdId;
    }
}

public sealed class CopyEngine
{
    readonly SymbolMap _symbols;
    readonly CopyLedger _ledger;
    readonly decimal _multiplier;

    public CopyEngine(SymbolMap symbols, CopyLedger ledger, decimal multiplier)
    {
        _symbols = symbols; _ledger = ledger; _multiplier = multiplier;
    }

    // Returns the order to send, or null with a reason when nothing must be sent.
    public OrderIntent Handle(MasterEvent e, out string skipped)
    {
        skipped = null;
        if (_ledger.AlreadyDone(e.Key)) { skipped = "duplicate " + e.Key; return null; }

        Instrument ins;
        if (!_symbols.TryResolve(e.Symbol, out ins)) { skipped = "unmapped symbol " + e.Symbol; return null; }

        string positionKey = e.Account + "-" + e.Ticket.ToString(CultureInfo.InvariantCulture);
        decimal held = _ledger.OpenQty(positionKey);
        decimal qty;
        bool buy;

        if (e.Kind == MasterEventKind.Open)
        {
            qty = Sizing.ToFollowerQty(e.Lots, _multiplier, ins);
            buy = e.Buy;
        }
        else
        {
            // The follower venue is netting: a close is an opposite order, never more than we hold.
            qty = e.Kind == MasterEventKind.Close ? held
                : Math.Min(held, Sizing.ToFollowerQty(e.Lots, _multiplier, ins));
            buy = !e.Buy;
        }

        if (qty <= 0m) { skipped = "nothing to do for " + e.Key; return null; }

        // In production: commit when the fill comes back, with the filled quantity.
        _ledger.Commit(e.Key, positionKey, e.Kind == MasterEventKind.Open ? qty : -qty);
        return new OrderIntent { ClOrdId = e.Key, FixSymbol = ins.FixSymbol, Buy = buy, Qty = qty };
    }
}
Enter fullscreen mode Exit fullscreen mode

Two decisions deserve a comment.

A close is an opposite order, and its size comes from the ledger, not from the event. If the master closes 0.20 of 0.50 lots and rounding left the follower holding slightly less than the proportional amount, Math.Min guarantees the follower never sells more than it bought for that ticket. On a full close the follower sends exactly what it holds. This is how you avoid the unexplained 0.07 lots.

The sketch commits to the ledger when it creates the order. A production copier commits in two steps: the intent before sending, the filled quantity when the ExecutionReport comes back. Partial fills on an IOC order are normal, and the ledger must hold what was filled, not what was wished for.

Problem 4: surviving a restart

Here is the scenario that kills naive copiers, as a test:

public static class Program
{
    static MasterEvent Ev(long ticket, MasterEventKind kind, int step, string symbol, bool buy, decimal lots)
    {
        return new MasterEvent { Account = "500123", Ticket = ticket, Kind = kind, Step = step,
                                 Symbol = symbol, Buy = buy, Lots = lots };
    }

    static void Feed(CopyEngine engine, MasterEvent e)
    {
        string skipped;
        OrderIntent o = engine.Handle(e, out skipped);
        Console.WriteLine("  " + e.Key.PadRight(16) + (o != null ? "-> " + o : ".. " + skipped));
    }

    public static void Main()
    {
        string path = Path.Combine(Path.GetTempPath(), "copier-ledger.txt");
        if (File.Exists(path)) File.Delete(path);

        var symbols = new SymbolMap(".r", ".ecn", "m");
        symbols.Add("EURUSD", new Instrument { FixSymbol = "EUR/USD", ContractSize = 100000m, MinQty = 1000m, QtyStep = 1000m });
        symbols.Add("XAUUSD", new Instrument { FixSymbol = "XAU/USD", ContractSize = 100m, MinQty = 1m, QtyStep = 1m });

        var events = new[]
        {
            Ev(1001, MasterEventKind.Open, 0, "EURUSD.r", true, 0.50m),
            Ev(1001, MasterEventKind.Open, 0, "EURUSD.r", true, 0.50m),          // the poller saw it twice
            Ev(1002, MasterEventKind.Open, 0, "XAUUSD.r", false, 0.10m),
            Ev(1003, MasterEventKind.Open, 0, "EURUSD.r", true, 0.03m),          // 1,500 units -> 1,000
            Ev(1004, MasterEventKind.Open, 0, "EURUSD.r", true, 0.01m),          // 500 units -> below minimum
            Ev(1005, MasterEventKind.Open, 0, "US30.r", true, 1.00m),            // not mapped
            Ev(1001, MasterEventKind.PartialClose, 1, "EURUSD.r", true, 0.20m),
        };

        Console.WriteLine("Session 1, multiplier 0.5");
        var engine = new CopyEngine(symbols, new CopyLedger(path), 0.5m);
        foreach (var e in events) Feed(engine, e);

        Console.WriteLine("Restart: the terminal replays everything it knows");
        engine = new CopyEngine(symbols, new CopyLedger(path), 0.5m);
        foreach (var e in events) Feed(engine, e);

        Console.WriteLine("Then the master closes what is left");
        Feed(engine, Ev(1001, MasterEventKind.Close, 0, "EURUSD.r", true, 0.30m));
        Feed(engine, Ev(1002, MasterEventKind.Close, 0, "XAUUSD.r", false, 0.10m));
    }
}
Enter fullscreen mode Exit fullscreen mode

The output:

Session 1, multiplier 0.5
  500123-1001-O0  -> BUY    25000 EUR/USD  ClOrdID=500123-1001-O0
  500123-1001-O0  .. duplicate 500123-1001-O0
  500123-1002-O0  -> SELL       5 XAU/USD  ClOrdID=500123-1002-O0
  500123-1003-O0  -> BUY     1000 EUR/USD  ClOrdID=500123-1003-O0
  500123-1004-O0  .. nothing to do for 500123-1004-O0
  500123-1005-O0  .. unmapped symbol US30.r
  500123-1001-P1  -> SELL   10000 EUR/USD  ClOrdID=500123-1001-P1
Restart: the terminal replays everything it knows
  500123-1001-O0  .. duplicate 500123-1001-O0
  500123-1001-O0  .. duplicate 500123-1001-O0
  500123-1002-O0  .. duplicate 500123-1002-O0
  500123-1003-O0  .. duplicate 500123-1003-O0
  500123-1004-O0  .. nothing to do for 500123-1004-O0
  500123-1005-O0  .. unmapped symbol US30.r
  500123-1001-P1  .. duplicate 500123-1001-P1
Then the master closes what is left
  500123-1001-C0  -> SELL   15000 EUR/USD  ClOrdID=500123-1001-C0
  500123-1002-C0  -> BUY        5 XAU/USD  ClOrdID=500123-1002-C0
Enter fullscreen mode Exit fullscreen mode

Session 1 shows the everyday cases: a duplicate is dropped, 0.10 lots of gold becomes 5 ounces, 1,500 units are rounded down to 1,000, 500 units fall below the minimum and are skipped, an unmapped index is refused. After the restart the same events produce no orders at all. Then the closes send exactly what each ticket still holds: 15,000 and 5.

The ledger makes replay harmless. It does not make reconciliation unnecessary. After any restart or FIX reconnect a real copier also asks the follower venue what is actually open (order status and position requests, where the broker supports them), compares that with the ledger and with the master's open positions, and raises an alarm on any difference instead of trading on it.

Where the milliseconds go, and when not to copy

On one host, with an event-driven master and a FIX follower leg, the copier's own share of the delay is small next to the network and the receiving broker. I broke the full master-fill-to-follower-fill interval into six components, with a measurement protocol, in a recent working paper: Trade Replication Latency in Retail Foreign Exchange.

The same paper makes an uncomfortable point for someone who sells a copier: some strategies should not be copied. If the edge lives for tens of milliseconds, as in latency arbitrage, no replication path is fast enough and the strategy should run natively at each venue. If the strategy is an MQL robot and the goal is simply FIX execution on one account, running the robot directly on a FIX connection removes the copy step altogether; FIX API Terminal documents how it runs MQL robots that way. A copier earns its place when one strategy has to reach several accounts, brokers or platforms at once.

One non-technical rule belongs in every copier design document: copying between accounts is governed by the terms of the brokers and prop firms involved, and those terms differ widely. We keep a running summary of which prop firms allow copy trading. Check before you connect anything.

Checklist

  • Give every master event a deterministic key, and use it as the ClOrdID.
  • Persist processed keys before you rely on them. Append-only is enough.
  • Map symbols explicitly. Skip and log what you cannot map.
  • Keep contract sizes in configuration. Round quantities down to the follower's step.
  • Size closes from your own ledger, never from the master's numbers alone.
  • Record filled quantities, not intended ones.
  • After a restart or reconnect: replay, then reconcile, then trade.
  • Prefer events to polling. If you must poll, remember the T/2.

I am Sergiy Lutsak (I also publish as Sergey Luts). I have been building high-frequency trading systems since 2000. More about my work: author page.

This article is about software engineering. It is not investment advice, and trading leveraged products carries a high risk of loss.

Top comments (0)