<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[FIX 4.4 in C# From Scratch: Build, Checksum and Parse Orders Without a Library]]></title><description><![CDATA[FIX 4.4 in C# From Scratch: Build, Checksum and Parse Orders Without a Library]]></description><link>https://fix-api.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>FIX 4.4 in C# From Scratch: Build, Checksum and Parse Orders Without a Library</title><link>https://fix-api.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 21 Sep 2026 16:28:30 GMT</lastBuildDate><atom:link href="https://fix-api.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[FIX 4.4 in C# From Scratch: Build, Checksum and Parse Orders Without a Library]]></title><description><![CDATA[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,]]></description><link>https://fix-api.hashnode.dev/fix-4-4-in-c-from-scratch-build-checksum-and-parse-orders-without-a-library</link><guid isPermaLink="true">https://fix-api.hashnode.dev/fix-4-4-in-c-from-scratch-build-checksum-and-parse-orders-without-a-library</guid><category><![CDATA[fintech]]></category><category><![CDATA[trading, ]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[c sharp]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Sergiy Lutsak]]></dc:creator><pubDate>Thu, 17 Sep 2026 15:48:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aac09761b200cb980726907/971b7cf8-eb73-4352-bdfb-4fc508c57e6a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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 <a href="https://quickfixn.org/">QuickFIX/n</a>, 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 <code>58=Invalid BodyLength</code> at 3 a.m., a black box is not what you want.</p>
<p>So this post goes the other way. We will build a FIX 4.4 <code>NewOrderSingle</code> by hand, compute the two fields everybody gets wrong, split a TCP stream into messages, and parse an <code>ExecutionReport</code> 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.</p>
<p>Disclosure, so you know where I am coming from: I build trading infrastructure for a living, including <a href="https://hftforexcopier.com/">HFT Forex Copier</a> and <a href="https://hftarbitrageplatform.com/en/">HFT Arbitrage Platform</a>. Both talk FIX to brokers all day, and this is the layer I end up debugging.</p>
<h2>The wire format in one minute</h2>
<p>A FIX message is a flat list of <code>tag=value</code> pairs. Each pair ends with the SOH byte (<code>0x01</code>). There are no brackets, no nesting and no whitespace. In logs SOH is usually printed as <code>|</code>, which is what I do below:</p>
<pre><code class="language-text">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|
</code></pre>
<p>Three rules give the message its shape:</p>
<ul>
<li><p>The first three fields are always <code>8</code> (BeginString), <code>9</code> (BodyLength) and <code>35</code> (MsgType), in that order.</p>
</li>
<li><p>The last field is always <code>10</code> (CheckSum).</p>
</li>
<li><p>Everything else is header (<code>49</code> sender, <code>56</code> target, <code>34</code> sequence number, <code>52</code> sending time) followed by the body for that message type.</p>
</li>
</ul>
<p><code>35=D</code> is a NewOrderSingle. <code>35=8</code> is an ExecutionReport. The <a href="https://www.fixtrading.org/standards/fix-4-4/">FIX 4.4 specification</a> lists the rest.</p>
<h2>BodyLength and CheckSum</h2>
<p>These two fields cause most first-week rejections.</p>
<p><strong>BodyLength (9)</strong> is the number of <em>bytes</em> after the SOH that ends the <code>9=</code> field, up to and including the SOH right before <code>10=</code>. Bytes, not characters, which matters the moment a text field contains anything outside ASCII.</p>
<p><strong>CheckSum (10)</strong> is the sum of every byte from the <code>8</code> of <code>8=FIX.4.4</code> through the SOH before <code>10=</code>, modulo 256, printed as exactly three digits. <code>67</code> is wrong. <code>067</code> is right.</p>
<pre><code class="language-csharp">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&lt;KeyValuePair&lt;int, string&gt;&gt; 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);
    }
}
</code></pre>
<p>Note the <code>InvariantCulture</code> everywhere. A server with a German or Russian locale will happily format <code>1.5</code> as <code>1,5</code>, and the broker will not be amused.</p>
<h2>A market order with IOC</h2>
<p>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: <code>59=3</code> (Immediate or Cancel) fills what it can right now and cancels the rest, <code>59=4</code> (Fill or Kill) is all or nothing.</p>
<pre><code class="language-csharp">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&lt;KeyValuePair&lt;int, string&gt;&gt;
        {
            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&lt;int, string&gt; F(int tag, string value)
    {
        return new KeyValuePair&lt;int, string&gt;(tag, value);
    }
}
</code></pre>
<p>That call produces exactly the message shown at the top of the post. For a limit order, set <code>40=2</code> and add <code>44=&lt;price&gt;</code>.</p>
<p>One warning from experience: every broker has a dialect. Some require <code>21=1</code> (HandlInst) even though 4.4 made it optional, some want <code>1=&lt;account&gt;</code>, some want <code>EURUSD</code> instead of <code>EUR/USD</code>. Read the broker's rules of engagement document before you write a line of code.</p>
<h2>FIX over TCP is a stream, not a packet</h2>
<p>This is the bug that survives testing and bites in production. One <code>Read()</code> 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 <code>8=...|9=NNN|</code>, you know the message ends <code>NNN + 7</code> bytes later, because the trailer <code>10=xxx|</code> is always seven bytes.</p>
<pre><code class="language-csharp">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&lt;SOH&gt;"
        if (count &lt; 2) return 0;
        if (buf[p] != (byte)'8' || buf[p + 1] != (byte)'=') return -1;
        while (p &lt; end &amp;&amp; buf[p] != SOH) p++;
        if (p == end) return 0;
        p++;

        // "9=&lt;digits&gt;&lt;SOH&gt;"
        if (end - p &lt; 2) return 0;
        if (buf[p] != (byte)'9' || buf[p + 1] != (byte)'=') return -1;
        p += 2;
        int bodyLen = 0, digits = 0;
        while (p &lt; end &amp;&amp; buf[p] != SOH)
        {
            int d = buf[p] - '0';
            if (d &lt; 0 || d &gt; 9 || ++digits &gt; 6) return -1;
            bodyLen = bodyLen * 10 + d;
            p++;
        }
        if (p == end) return 0;
        p++;

        // body, then "10=xxx&lt;SOH&gt;" which is always 7 bytes
        int total = (p - start) + bodyLen + 7;
        return total &lt;= count ? total : 0;
    }

    public static bool ChecksumOk(byte[] buf, int start, int length)
    {
        int trailer = start + length - 7;               // position of "10="
        if (length &lt; 7 || buf[trailer] != (byte)'1' || buf[trailer + 1] != (byte)'0'
                       || buf[trailer + 2] != (byte)'=') return false;
        int sum = 0;
        for (int i = start; i &lt; trailer; i++) sum += buf[i];
        int expected = (buf[trailer + 3] - '0') * 100 + (buf[trailer + 4] - '0') * 10
                     + (buf[trailer + 5] - '0');
        return (sum &amp; 0xFF) == expected;
    }
}
</code></pre>
<p>Your receive loop appends socket bytes to a buffer, calls <code>TryFrame</code> until it returns <code>0</code>, and keeps the leftover bytes for the next read.</p>
<h2>Parsing an ExecutionReport without allocating</h2>
<p>The obvious parser is <code>message.Split('\x01')</code> followed by <code>Split('=')</code> and a <code>Dictionary&lt;int, string&gt;</code>. 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.</p>
<p>The alternative is to walk the bytes once and only note where each value lives:</p>
<pre><code class="language-csharp">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 &lt; end &amp;&amp; buf[pos] != (byte)'=')
        {
            int d = buf[pos] - '0';
            if (d &lt; 0 || d &gt; 9) return false;
            tag = tag * 10 + d;
            pos++;
        }
        if (pos &gt;= end) return false;
        pos++;                                   // skip '='
        int valueStart = pos;
        while (pos &lt; end &amp;&amp; buf[pos] != SOH) pos++;
        if (pos &gt;= 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 &lt; 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 &amp; 0xFFFFFFFF), (int)(mantissa &gt;&gt; 32), 0, neg, (byte)scale);
    }
}
</code></pre>
<p>Prices are <code>decimal</code> on purpose. <code>1.08452</code> has no exact <code>double</code> representation, and you do not want to explain a one-pip reconciliation break caused by binary floating point.</p>
<p>On top of the reader, the ExecutionReport parser is a <code>switch</code>:</p>
<pre><code class="language-csharp">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 &amp;&amp; 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;
    }
}
</code></pre>
<p>A detail that trips people moving from FIX 4.2: in 4.4 a fill arrives as <code>150=F</code> (Trade), and you tell partial from full by <code>39</code> and <code>151</code>. The old <code>150=1</code> and <code>150=2</code> values were dropped from the standard, although a few broker dialects still send them, so check what yours does.</p>
<h2>What does all of this cost?</h2>
<p>Measure it. Warm up first so the JIT is out of the picture, then time a million iterations:</p>
<pre><code class="language-csharp">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&lt;KeyValuePair&lt;int, string&gt;&gt;
            {
                new KeyValuePair&lt;int, string&gt;(37, "B-778812"), new KeyValuePair&lt;int, string&gt;(11, "ORD-0001"),
                new KeyValuePair&lt;int, string&gt;(17, "E-1"),      new KeyValuePair&lt;int, string&gt;(150, "F"),
                new KeyValuePair&lt;int, string&gt;(39, "2"),        new KeyValuePair&lt;int, string&gt;(55, "EUR/USD"),
                new KeyValuePair&lt;int, string&gt;(54, "1"),        new KeyValuePair&lt;int, string&gt;(32, "100000"),
                new KeyValuePair&lt;int, string&gt;(31, "1.08452"),  new KeyValuePair&lt;int, string&gt;(151, "0"),
            }));

        const int N = 1000000;
        Fill fill;
        for (int i = 0; i &lt; 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 &lt; 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 &lt; N; i++)
            if (FixFramer.TryFrame(er, 0, er.Length) &gt; 0 &amp;&amp; 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);
    }
}
</code></pre>
<p>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.</p>
<p>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 <a href="https://hftforexcopier.com/cloud-vs-local-trade-copier-latency-benchmark/">local vs cloud copier latency breakdown</a>. Message encoding is three orders of magnitude below the network.</p>
<p>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 <a href="https://hftarbitrageplatform.com/en/latency-arbitrage/">latency arbitrage</a>, the tail is where the money is lost.</p>
<h2>What this post skipped: the session layer</h2>
<p>A working FIX connection also needs:</p>
<ul>
<li><p>Logon (<code>35=A</code>) with <code>98=0</code> and <code>108=&lt;heartbeat seconds&gt;</code>, usually a username and password in <code>553</code> and <code>554</code>, and often <code>141=Y</code> to reset sequence numbers.</p>
</li>
<li><p>Heartbeat (<code>35=0</code>) and TestRequest (<code>35=1</code>) handling, or the broker drops you.</p>
</li>
<li><p>Sequence numbers (<code>34</code>) that persist across reconnects, ResendRequest (<code>35=2</code>) and SequenceReset (<code>35=4</code>) for gap recovery.</p>
</li>
<li><p>Logout (<code>35=5</code>) and a reconnect policy.</p>
</li>
</ul>
<p>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.</p>
<h2>Do you need to write any of this yourself?</h2>
<p>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 <a href="https://hftforexcopier.com/fix-api-copier/">FIX API copier</a> does), or the strategy moves to a native FIX client and MetaTrader leaves the path entirely (<a href="https://fixapiterminal.com/">FIX API Terminal</a> 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.</p>
<h2>Checklist before you hit a live session</h2>
<ul>
<li><p>BodyLength in bytes, CheckSum as three digits.</p>
</li>
<li><p>All timestamps in UTC, <code>yyyyMMdd-HH:mm:ss.fff</code>.</p>
</li>
<li><p><code>InvariantCulture</code> for every number you format or parse.</p>
</li>
<li><p><code>decimal</code> for prices and quantities.</p>
</li>
<li><p>Frame the TCP stream; never assume one read equals one message.</p>
</li>
<li><p>Persist sequence numbers.</p>
</li>
<li><p>Log raw messages with SOH replaced by <code>|</code>. You will need them.</p>
</li>
<li><p>Test against the broker's UAT or demo endpoint first, with their rules of engagement open in another window.</p>
</li>
</ul>
<hr />
<p><em>I am Sergiy Lutsak (I also publish as Sergey Luts). I have been building high-frequency trading systems since 2000. More about my work:</em> <a href="https://hftforexcopier.com/sergey-luts-high-frequency-trading-systems-developer/"><em>author page</em></a><em>.</em></p>
<p><em>This article is about software engineering. It is not investment advice, and trading leveraged products carries a high risk of loss.</em></p>
]]></content:encoded></item></channel></rss>