Every broker that offers "FIX API access" is offering the same thing underneath: a TCP socket that speaks the Financial Information eXchange protocol. Most .NET developers meet FIX through QuickFIX/n, which is the right choice for a production session layer. It is also a big black box, and when a broker rejects your order with 58=Invalid BodyLength at 3 a.m., a black box is not what you want.
So this post goes the other way. We will build a FIX 4.4 NewOrderSingle by hand, compute the two fields everybody gets wrong, split a TCP stream into messages, and parse an ExecutionReport without allocating. Plain C#, no packages. Everything compiles as C# 7, so it runs on .NET Framework 4.8 as well as on current .NET.
Disclosure, so you know where I am coming from: I build trading infrastructure for a living, including HFT Forex Copier and HFT Arbitrage Platform. Both talk FIX to brokers all day, and this is the layer I end up debugging.
The wire format in one minute
A FIX message is a flat list of tag=value pairs. Each pair ends with the SOH byte (0x01). There are no brackets, no nesting and no whitespace. In logs SOH is usually printed as |, which is what I do below:
8=FIX.4.4|9=130|35=D|49=CLIENT1|56=BROKER|34=42|52=20260917-14:30:05.123|11=ORD-0001|55=EUR/USD|54=1|60=20260917-14:30:05.123|38=100000|40=1|59=3|10=067|
Three rules give the message its shape:
- The first three fields are always
8(BeginString),9(BodyLength) and35(MsgType), in that order. - The last field is always
10(CheckSum). - Everything else is header (
49sender,56target,34sequence number,52sending time) followed by the body for that message type.
35=D is a NewOrderSingle. 35=8 is an ExecutionReport. The FIX 4.4 specification lists the rest.
BodyLength and CheckSum
These two fields cause most first-week rejections.
BodyLength (9) is the number of bytes after the SOH that ends the 9= field, up to and including the SOH right before 10=. Bytes, not characters, which matters the moment a text field contains anything outside ASCII.
CheckSum (10) is the sum of every byte from the 8 of 8=FIX.4.4 through the SOH before 10=, modulo 256, printed as exactly three digits. 67 is wrong. 067 is right.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Text;
public static class FixWriter
{
public const char SOH = '\x01';
public static string Build(string msgType, string sender, string target,
int seqNum, DateTime utcNow,
IEnumerable<KeyValuePair<int, string>> fields)
{
// 1. Everything between BodyLength(9) and CheckSum(10)
var body = new StringBuilder(256);
Add(body, 35, msgType);
Add(body, 49, sender);
Add(body, 56, target);
Add(body, 34, seqNum.ToString(CultureInfo.InvariantCulture));
Add(body, 52, utcNow.ToString("yyyyMMdd-HH:mm:ss.fff", CultureInfo.InvariantCulture));
foreach (var f in fields) Add(body, f.Key, f.Value);
// 2. BeginString(8) + BodyLength(9). Length is counted in BYTES.
var msg = new StringBuilder(body.Length + 32);
Add(msg, 8, "FIX.4.4");
Add(msg, 9, Encoding.ASCII.GetByteCount(body.ToString())
.ToString(CultureInfo.InvariantCulture));
msg.Append(body);
// 3. CheckSum(10): sum of every byte so far, modulo 256, always 3 digits.
int sum = 0;
foreach (byte b in Encoding.ASCII.GetBytes(msg.ToString())) sum += b;
Add(msg, 10, (sum % 256).ToString("D3", CultureInfo.InvariantCulture));
return msg.ToString();
}
static void Add(StringBuilder sb, int tag, string value)
{
sb.Append(tag.ToString(CultureInfo.InvariantCulture)).Append('=').Append(value).Append(SOH);
}
}
Note the InvariantCulture everywhere. A server with a German or Russian locale will happily format 1.5 as 1,5, and the broker will not be amused.
A market order with IOC
In FIX 4.4 a NewOrderSingle needs a client order id, an instrument, a side, a transaction time, a quantity and an order type. For FX I almost always add a time in force, because that is your slippage control: 59=3 (Immediate or Cancel) fills what it can right now and cancels the rest, 59=4 (Fill or Kill) is all or nothing.
public static class Orders
{
public static string MarketIoc(string sender, string target, int seqNum, DateTime utcNow,
string clOrdId, string symbol, bool buy, decimal qty)
{
string ts = utcNow.ToString("yyyyMMdd-HH:mm:ss.fff", CultureInfo.InvariantCulture);
var fields = new List<KeyValuePair<int, string>>
{
F(11, clOrdId), // ClOrdID: your unique id
F(55, symbol), // Symbol, e.g. EUR/USD
F(54, buy ? "1" : "2"), // Side: 1=Buy, 2=Sell
F(60, ts), // TransactTime (UTC)
F(38, qty.ToString(CultureInfo.InvariantCulture)), // OrderQty
F(40, "1"), // OrdType: 1=Market
F(59, "3"), // TimeInForce: 3=IOC
};
return FixWriter.Build("D", sender, target, seqNum, utcNow, fields);
}
static KeyValuePair<int, string> F(int tag, string value)
{
return new KeyValuePair<int, string>(tag, value);
}
}
That call produces exactly the message shown at the top of the post. For a limit order, set 40=2 and add 44=<price>.
One warning from experience: every broker has a dialect. Some require 21=1 (HandlInst) even though 4.4 made it optional, some want 1=<account>, some want EURUSD instead of EUR/USD. Read the broker's rules of engagement document before you write a line of code.
FIX over TCP is a stream, not a packet
This is the bug that survives testing and bites in production. One Read() from the socket can return half a message, or two and a half messages. You have to frame the stream yourself, and BodyLength is what makes that possible: once you have read 8=...|9=NNN|, you know the message ends NNN + 7 bytes later, because the trailer 10=xxx| is always seven bytes.
public static class FixFramer
{
const byte SOH = 1;
// Returns the full length of the first message in buf[start..start+count),
// or 0 if more bytes are needed, or -1 if the stream is garbage.
public static int TryFrame(byte[] buf, int start, int count)
{
int end = start + count;
int p = start;
// "8=FIX.x.y<SOH>"
if (count < 2) return 0;
if (buf[p] != (byte)'8' || buf[p + 1] != (byte)'=') return -1;
while (p < end && buf[p] != SOH) p++;
if (p == end) return 0;
p++;
// "9=<digits><SOH>"
if (end - p < 2) return 0;
if (buf[p] != (byte)'9' || buf[p + 1] != (byte)'=') return -1;
p += 2;
int bodyLen = 0, digits = 0;
while (p < end && buf[p] != SOH)
{
int d = buf[p] - '0';
if (d < 0 || d > 9 || ++digits > 6) return -1;
bodyLen = bodyLen * 10 + d;
p++;
}
if (p == end) return 0;
p++;
// body, then "10=xxx<SOH>" which is always 7 bytes
int total = (p - start) + bodyLen + 7;
return total <= count ? total : 0;
}
public static bool ChecksumOk(byte[] buf, int start, int length)
{
int trailer = start + length - 7; // position of "10="
if (length < 7 || buf[trailer] != (byte)'1' || buf[trailer + 1] != (byte)'0'
|| buf[trailer + 2] != (byte)'=') return false;
int sum = 0;
for (int i = start; i < trailer; i++) sum += buf[i];
int expected = (buf[trailer + 3] - '0') * 100 + (buf[trailer + 4] - '0') * 10
+ (buf[trailer + 5] - '0');
return (sum & 0xFF) == expected;
}
}
Your receive loop appends socket bytes to a buffer, calls TryFrame until it returns 0, and keeps the leftover bytes for the next read.
Parsing an ExecutionReport without allocating
The obvious parser is message.Split('\x01') followed by Split('=') and a Dictionary<int, string>. It works, and it allocates a few dozen objects per message. On a quiet session nobody cares. On a busy market data or execution session that is steady garbage, and garbage collections show up exactly where you least want them: in the tail of your latency distribution.
The alternative is to walk the bytes once and only note where each value lives:
public struct FixField
{
public int Tag;
public int Offset; // where the value starts in the buffer
public int Length; // value length in bytes
}
public static class FixReader
{
const byte SOH = 1;
public static bool TryRead(byte[] buf, ref int pos, int end, out FixField field)
{
field = default(FixField);
int tag = 0;
while (pos < end && buf[pos] != (byte)'=')
{
int d = buf[pos] - '0';
if (d < 0 || d > 9) return false;
tag = tag * 10 + d;
pos++;
}
if (pos >= end) return false;
pos++; // skip '='
int valueStart = pos;
while (pos < end && buf[pos] != SOH) pos++;
if (pos >= end) return false;
field.Tag = tag;
field.Offset = valueStart;
field.Length = pos - valueStart;
pos++; // skip SOH
return true;
}
// Parses "1.08452" style numbers straight from ASCII bytes. No strings, no culture.
public static decimal ToDecimal(byte[] buf, int offset, int length)
{
long mantissa = 0;
int scale = 0;
bool neg = false, seenDot = false;
for (int i = offset; i < offset + length; i++)
{
byte c = buf[i];
if (c == (byte)'-') { neg = true; continue; }
if (c == (byte)'.') { seenDot = true; continue; }
mantissa = mantissa * 10 + (c - '0');
if (seenDot) scale++;
}
return new decimal((int)(mantissa & 0xFFFFFFFF), (int)(mantissa >> 32), 0, neg, (byte)scale);
}
}
Prices are decimal on purpose. 1.08452 has no exact double representation, and you do not want to explain a one-pip reconciliation break caused by binary floating point.
On top of the reader, the ExecutionReport parser is a switch:
public struct Fill
{
public char ExecType; // 150: '0'=New, 'F'=Trade, '8'=Rejected
public char OrdStatus; // 39: '1'=Partially filled, '2'=Filled, '8'=Rejected
public decimal LastQty; // 32
public decimal LastPx; // 31
public decimal LeavesQty;// 151
public int ClOrdIdOffset, ClOrdIdLength; // 11
}
public static class ExecutionReports
{
public static bool TryParse(byte[] buf, int start, int length, out Fill fill)
{
fill = default(Fill);
int pos = start, end = start + length;
bool isExecReport = false;
FixField f;
while (FixReader.TryRead(buf, ref pos, end, out f))
{
switch (f.Tag)
{
case 35: isExecReport = f.Length == 1 && buf[f.Offset] == (byte)'8'; break;
case 11: fill.ClOrdIdOffset = f.Offset; fill.ClOrdIdLength = f.Length; break;
case 150: fill.ExecType = (char)buf[f.Offset]; break;
case 39: fill.OrdStatus = (char)buf[f.Offset]; break;
case 32: fill.LastQty = FixReader.ToDecimal(buf, f.Offset, f.Length); break;
case 31: fill.LastPx = FixReader.ToDecimal(buf, f.Offset, f.Length); break;
case 151: fill.LeavesQty = FixReader.ToDecimal(buf, f.Offset, f.Length); break;
}
}
return isExecReport;
}
}
A detail that trips people moving from FIX 4.2: in 4.4 a fill arrives as 150=F (Trade), and you tell partial from full by 39 and 151. The old 150=1 and 150=2 values were dropped from the standard, although a few broker dialects still send them, so check what yours does.
What does all of this cost?
Measure it. Warm up first so the JIT is out of the picture, then time a million iterations:
public static class Program
{
public static void Main()
{
var t = DateTime.UtcNow;
var er = Encoding.ASCII.GetBytes(FixWriter.Build("8", "BROKER", "CLIENT1", 97, t,
new List<KeyValuePair<int, string>>
{
new KeyValuePair<int, string>(37, "B-778812"), new KeyValuePair<int, string>(11, "ORD-0001"),
new KeyValuePair<int, string>(17, "E-1"), new KeyValuePair<int, string>(150, "F"),
new KeyValuePair<int, string>(39, "2"), new KeyValuePair<int, string>(55, "EUR/USD"),
new KeyValuePair<int, string>(54, "1"), new KeyValuePair<int, string>(32, "100000"),
new KeyValuePair<int, string>(31, "1.08452"), new KeyValuePair<int, string>(151, "0"),
}));
const int N = 1000000;
Fill fill;
for (int i = 0; i < 100000; i++) // warm-up
{
Orders.MarketIoc("CLIENT1", "BROKER", i, t, "ORD-0001", "EUR/USD", true, 100000m);
ExecutionReports.TryParse(er, 0, er.Length, out fill);
}
var sw = Stopwatch.StartNew();
for (int i = 0; i < N; i++)
Orders.MarketIoc("CLIENT1", "BROKER", i, t, "ORD-0001", "EUR/USD", true, 100000m);
sw.Stop();
Console.WriteLine("build: {0:F0} ns/op", sw.Elapsed.TotalMilliseconds * 1e6 / N);
int gen0 = GC.CollectionCount(0);
sw.Restart();
for (int i = 0; i < N; i++)
if (FixFramer.TryFrame(er, 0, er.Length) > 0 && FixFramer.ChecksumOk(er, 0, er.Length))
ExecutionReports.TryParse(er, 0, er.Length, out fill);
sw.Stop();
Console.WriteLine("frame + checksum + parse: {0:F0} ns/op, gen0 collections: {1}",
sw.Elapsed.TotalMilliseconds * 1e6 / N, GC.CollectionCount(0) - gen0);
}
}
Expect single-digit microseconds for the string-based builder and well under a microsecond for the frame, checksum and parse path, with zero gen-0 collections on the read side. Your exact numbers depend on runtime and CPU. The orders of magnitude do not.
Now put that next to everything else in the life of an order. A network round trip to a broker from a nearby VPS is on the order of a millisecond, and the broker's own matching and fill takes milliseconds to tens of milliseconds. I published a stage-by-stage table of those ranges in a local vs cloud copier latency breakdown. Message encoding is three orders of magnitude below the network.
So why bother with the allocation-free parser? Because averages are not the problem. A blocking garbage collection at the wrong moment can cost you milliseconds, not microseconds, and it lands in your p99. For most strategies that is noise. For anything that lives on short-lived price discrepancies, such as latency arbitrage, the tail is where the money is lost.
What this post skipped: the session layer
A working FIX connection also needs:
- Logon (
35=A) with98=0and108=<heartbeat seconds>, usually a username and password in553and554, and often141=Yto reset sequence numbers. - Heartbeat (
35=0) and TestRequest (35=1) handling, or the broker drops you. - Sequence numbers (
34) that persist across reconnects, ResendRequest (35=2) and SequenceReset (35=4) for gap recovery. - Logout (
35=5) and a reconnect policy.
This is exactly what QuickFIX/n gives you, and unless you have a measured reason to replace it, use it. Understanding the wire format is what lets you read its logs.
Do you need to write any of this yourself?
If your goal is to build trading infrastructure, yes, and the snippets above are a reasonable start. If your goal is just to get orders from a strategy onto a FIX account, retail traders usually take one of two routes. Either the strategy stays in MetaTrader and a copier mirrors its trades onto the FIX account (this is what our FIX API copier does), or the strategy moves to a native FIX client and MetaTrader leaves the path entirely (FIX API Terminal is one example, and it can run MQL robots directly on a FIX session). Either way, what goes over the wire is what you have just read.
Checklist before you hit a live session
- BodyLength in bytes, CheckSum as three digits.
- All timestamps in UTC,
yyyyMMdd-HH:mm:ss.fff. -
InvariantCulturefor every number you format or parse. -
decimalfor prices and quantities. - Frame the TCP stream; never assume one read equals one message.
- Persist sequence numbers.
- Log raw messages with SOH replaced by
|. You will need them. - Test against the broker's UAT or demo endpoint first, with their rules of engagement open in another window.
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.
int gen0 = GC.CollectionCount(0);
sw.Restart();
for (int i = 0; i < N; i++)
if (FixFramer.TryFrame(er, 0, er.Length) > 0 && FixFramer.ChecksumOk(er, 0, er.Length))
ExecutionReports.TryParse(er, 0, er.Length, out fill);
sw.Stop();
Console.WriteLine("frame + checksum + parse: {0:F0} ns/op, gen0 collections: {1}",
sw.Elapsed.TotalMilliseconds * 1e6 / N, GC.CollectionCount(0) - gen0);
}
}
Expect single-digit microseconds for the string-based builder and well under a microsecond for the frame, checksum and parse path, with zero gen-0 collections on the read side. Your exact numbers depend on runtime and CPU. The orders of magnitude do not.
Now put that next to everything else in the life of an order. A network round trip to a broker from a nearby VPS is on the order of a millisecond, and the broker's own matching and fill takes milliseconds to tens of milliseconds. I published a stage-by-stage table of those ranges in a local vs cloud copier latency breakdown. Message encoding is three orders of magnitude below the network.
So why bother with the allocation-free parser? Because averages are not the problem. A blocking garbage collection at the wrong moment can cost you milliseconds, not microseconds, and it lands in your p99. For most strategies that is noise. For anything that lives on short-lived price discrepancies, such as latency arbitrage, the tail is where the money is lost.
What this post skipped: the session layer
A working FIX connection also needs:
Logon (35=A) with 98=0 and 108=, usually a username and password in 553 and 554, and often 141=Y to reset sequence numbers.
Heartbeat (35=0) and TestRequest (35=1) handling, or the broker drops you.
Sequence numbers (34) that persist across reconnects, ResendRequest (35=2) and SequenceReset (35=4) for gap recovery.
Logout (35=5) and a reconnect policy.
This is exactly what QuickFIX/n gives you, and unless you have a measured reason to replace it, use it. Understanding the wire format is what lets you read its logs.
Do you need to write any of this yourself?
If your goal is to build trading infrastructure, yes, and the snippets above are a reasonable start. If your goal is just to get orders from a strategy onto a FIX account, retail traders usually take one of two routes. Either the strategy stays in MetaTrader and a copier mirrors its trades onto the FIX account (this is what our FIX API copier does), or the strategy moves to a native FIX client and MetaTrader leaves the path entirely (FIX API Terminal is one example, and it can run MQL robots directly on a FIX session). Either way, what goes over the wire is what you have just read.
Checklist before you hit a live session
BodyLength in bytes, CheckSum as three digits.
All timestamps in UTC, yyyyMMdd-HH:mm:ss.fff.
InvariantCulture for every number you format or parse.
decimal for prices and quantities.
Frame the TCP stream; never assume one read equals one message.
Persist sequence numbers.
Log raw messages with SOH replaced by |. You will need them.
Test against the broker's UAT or demo endpoint first, with their rules of engagement open in another window.
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)