In part 1 we built a FIX 4.4 order by hand, framed a TCP stream and parsed an ExecutionReport. That is enough to understand a log file. It is not enough to stay connected: send that order to a real broker without a session around it and you get silence, then a dropped socket.
The session layer is everything that is not about trading: who we are, are you still there, which message number are we on, and what did I miss while I was away. It is about 200 lines of code and most of the production incidents.
Same disclosure as before: I build HFT Forex Copier and other trading infrastructure at HFT Software, where a FIX session that recovers cleanly at 3 a.m. matters more than one that is a microsecond faster.
The code below reuses FixWriter, FixReader and FixField from part 1. As before it is plain C# 7 with no packages, so it runs on .NET Framework 4.8 and on current .NET.
What the session layer is responsible for
Seven administrative message types do all the work:
| MsgType | Name | Job |
|---|---|---|
A |
Logon | Authenticate, agree the heartbeat interval, optionally reset sequence numbers |
0 |
Heartbeat | "I am alive", sent when nothing else was sent for one interval |
1 |
TestRequest | "Are you alive?", the other side must answer with a Heartbeat echoing 112
|
2 |
ResendRequest | "I missed messages from number N, send them again" |
4 |
SequenceReset | "Skip ahead to number N", used to answer a ResendRequest without replaying |
3 |
Reject | "Your message broke session rules" |
5 |
Logout | Orderly goodbye |
Underneath all of it sits one rule: every message in each direction carries 34=MsgSeqNum, the numbers increase by exactly one, and both sides remember where they are, across disconnects. The authoritative text is the FIX Session Layer specification.
Sequence numbers must survive a restart
If your process restarts and starts again from 1 while the broker expects 5,812, the broker will refuse the logon. So the two counters live on disk, not in a field:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
public interface ISeqStore
{
int NextOut { get; set; } // sequence number of the next message we send
int NextIn { get; set; } // sequence number we expect to receive next
}
public sealed class FileSeqStore : ISeqStore
{
readonly string _path;
int _out = 1, _in = 1;
public FileSeqStore(string path)
{
_path = path;
if (File.Exists(path))
{
string[] p = File.ReadAllText(path).Split(':');
_out = int.Parse(p[0], CultureInfo.InvariantCulture);
_in = int.Parse(p[1], CultureInfo.InvariantCulture);
}
}
public int NextOut { get { return _out; } set { _out = value; Save(); } }
public int NextIn { get { return _in; } set { _in = value; Save(); } }
void Save()
{
File.WriteAllText(_path, _out.ToString(CultureInfo.InvariantCulture) + ":" +
_in.ToString(CultureInfo.InvariantCulture));
}
}
Rewriting a tiny file on every message is fine for an order session that sends a few messages a second. For a market data session you would batch or memory-map it. Many retail brokers sidestep the problem by asking you to log on with 141=Y (ResetSeqNumFlag), which restarts both sides at 1 on every connection. That is simpler, and it also means anything you missed while disconnected is gone, so you must reconcile open orders and positions yourself after each logon.
Outbound side: Logon and a clock you can control
Two design choices make this class testable. The session never touches a socket, it calls a send delegate. And it never reads the system clock, it calls an injected utcNow. With those two seams you can run a full day of heartbeats in a unit test in a millisecond.
public sealed class FixSession
{
readonly string _sender, _target;
readonly ISeqStore _seq;
readonly Action<string> _send; // writes one complete message to the socket
readonly Func<DateTime> _utcNow; // injected clock: the session never calls DateTime.UtcNow
int _heartBtInt = 30;
DateTime _lastSent, _lastReceived;
bool _testRequestPending, _resendPending;
public bool LoggedOn { get; private set; }
public event Action<byte[], int, int> ApplicationMessage; // fills, rejects, market data
public event Action<string> Disconnected;
public FixSession(string sender, string target, ISeqStore seq,
Action<string> send, Func<DateTime> utcNow)
{
_sender = sender; _target = target; _seq = seq; _send = send; _utcNow = utcNow;
_lastSent = _lastReceived = utcNow();
}
public void Logon(string username, string password, int heartBtInt, bool resetSeqNum,
params KeyValuePair<int, string>[] dialectHeader)
{
_heartBtInt = heartBtInt;
if (resetSeqNum) { _seq.NextOut = 1; _seq.NextIn = 1; }
var f = new List<KeyValuePair<int, string>>(dialectHeader);
f.Add(F(98, "0")); // EncryptMethod: none
f.Add(F(108, heartBtInt.ToString(CultureInfo.InvariantCulture))); // HeartBtInt, seconds
if (resetSeqNum) f.Add(F(141, "Y")); // ResetSeqNumFlag
f.Add(F(553, username));
f.Add(F(554, password));
Send("A", f);
}
public void SendApplication(string msgType, List<KeyValuePair<int, string>> fields)
{
if (!LoggedOn) throw new InvalidOperationException("Not logged on");
Send(msgType, fields);
}
void Send(string msgType, List<KeyValuePair<int, string>> fields)
{
int seq = _seq.NextOut;
_seq.NextOut = seq + 1; // persist BEFORE the bytes leave
_lastSent = _utcNow();
_send(FixWriter.Build(msgType, _sender, _target, seq, _lastSent, fields));
}
The order inside Send is deliberate. If the process dies after the counter is saved but before the bytes leave, the next message arrives with a number one higher than the broker expects. The broker asks for a resend, we answer with a gap fill, and life goes on. The opposite order can reuse a number the broker has already seen, and a sequence number that is too low is a fatal error.
The dialectHeader parameter exists because brokers differ. The cTrader FIX API, for example, runs separate price and trade sessions and tells them apart by 57=TRADE or 57=QUOTE in the header, as its specification describes. That is the kind of detail a product like our MT4 to cTrader copier has to get right per venue, and it is why the first document to read is always the broker's own rules of engagement.
Heartbeats and the dead line problem
TCP will not tell you promptly that the other side has vanished. A pulled cable or a frozen gateway can leave a socket looking healthy for minutes. FIX solves it at the application level:
// call once a second
public void OnTimer()
{
if (!LoggedOn) return;
DateTime now = _utcNow();
double quiet = (now - _lastReceived).TotalSeconds;
if (_testRequestPending && quiet >= 2 * _heartBtInt)
{
Drop("No reply to TestRequest");
return;
}
if (!_testRequestPending && quiet >= 1.2 * _heartBtInt)
{
_testRequestPending = true;
Send("1", Fields(F(112, "TEST-" + now.ToString("HHmmss", CultureInfo.InvariantCulture))));
return;
}
if ((now - _lastSent).TotalSeconds >= _heartBtInt)
Send("0", Fields());
}
Three rules. If we sent nothing for one interval, send a Heartbeat. If we heard nothing for a bit more than one interval, send a TestRequest. If that also goes unanswered, declare the line dead and reconnect. The 1.2 and 2 multipliers are my choice; the specification only asks for the interval plus a reasonable allowance for transmission time.
With a 30 second interval, worst case detection takes about a minute. If a minute of not knowing whether your orders are live is too long, negotiate a shorter 108.
Inbound side: the sequence check comes first
Every inbound message goes through the same gate before its type matters:
// one framed, checksum-verified message (see part 1)
public void OnMessage(byte[] buf, int start, int length)
{
string msgType = null, testReqId = null;
int seqNum = 0, beginSeqNo = 0, newSeqNo = 0;
bool possDup = false, gapFill = false;
int pos = start, end = start + length;
FixField f;
while (FixReader.TryRead(buf, ref pos, end, out f))
{
switch (f.Tag)
{
case 35: msgType = Encoding.ASCII.GetString(buf, f.Offset, f.Length); break;
case 34: seqNum = ToInt(buf, f); break;
case 43: possDup = buf[f.Offset] == (byte)'Y'; break;
case 112: testReqId = Encoding.ASCII.GetString(buf, f.Offset, f.Length); break;
case 7: beginSeqNo = ToInt(buf, f); break;
case 36: newSeqNo = ToInt(buf, f); break;
case 123: gapFill = buf[f.Offset] == (byte)'Y'; break;
}
}
_lastReceived = _utcNow();
_testRequestPending = false;
// SequenceReset in Reset mode ignores MsgSeqNum by definition
if (msgType == "4" && !gapFill) { _seq.NextIn = newSeqNo; return; }
int expected = _seq.NextIn;
if (seqNum > expected)
{
// We missed something. Ask once for everything from the gap onwards (16=0 = "to infinity").
if (!_resendPending)
{
_resendPending = true;
Send("2", Fields(F(7, expected.ToString(CultureInfo.InvariantCulture)), F(16, "0")));
}
return; // do not process out-of-order messages
}
if (seqNum < expected)
{
if (possDup) return; // a resend of something we already have
Drop("MsgSeqNum too low: expected " + expected + ", got " + seqNum);
return;
}
_seq.NextIn = (msgType == "4") ? newSeqNo : expected + 1;
_resendPending = false;
switch (msgType)
{
case "A": LoggedOn = true; break;
case "0": break; // Heartbeat
case "1": Send("0", Fields(F(112, testReqId))); break; // TestRequest -> Heartbeat
case "2": AnswerResendRequest(beginSeqNo); break;
case "4": break; // GapFill, already applied
case "5": Drop("Logout from counterparty"); break;
default:
var handler = ApplicationMessage;
if (handler != null) handler(buf, start, length);
break;
}
}
The three branches are the heart of FIX recovery:
-
Too high means we missed messages. We ask for them and, importantly, we do not process the message that revealed the gap. A fill delivered out of order is how position state gets corrupted. It will come again, flagged
43=Y. -
Too low without
43=Ymeans the two sides disagree about history. There is no safe automatic fix, so the session drops and a human looks at it. - Exactly right advances the counter, and only then does the message type matter.
Answering a ResendRequest without replaying orders
When the broker asks us to resend, the literal answer is to replay every message in the range. For an order flow that is dangerous: a NewOrderSingle that was relevant forty seconds ago is not an order you want executed now. The common policy is to replay nothing and cover the whole range with a single SequenceReset in gap fill mode:
// Policy: never replay old orders. Cover the whole requested range with one GapFill.
void AnswerResendRequest(int beginSeqNo)
{
DateTime now = _utcNow();
string ts = now.ToString("yyyyMMdd-HH:mm:ss.fff", CultureInfo.InvariantCulture);
var f = Fields(F(43, "Y"), // PossDupFlag
F(122, ts), // OrigSendingTime, required with 43=Y
F(123, "Y"), // GapFillFlag
F(36, _seq.NextOut.ToString(CultureInfo.InvariantCulture))); // NewSeqNo
_lastSent = now;
// A GapFill carries the sequence number of the first message it replaces.
_send(FixWriter.Build("4", _sender, _target, beginSeqNo, now, f));
}
void Drop(string reason)
{
LoggedOn = false;
var handler = Disconnected;
if (handler != null) handler(reason);
}
static int ToInt(byte[] buf, FixField f)
{
int v = 0;
for (int i = f.Offset; i < f.Offset + f.Length; i++) v = v * 10 + (buf[i] - '0');
return v;
}
static KeyValuePair<int, string> F(int tag, string value)
{
return new KeyValuePair<int, string>(tag, value);
}
static List<KeyValuePair<int, string>> Fields(params KeyValuePair<int, string>[] items)
{
return new List<KeyValuePair<int, string>>(items);
}
}
Two details are easy to get wrong. The gap fill does not consume a new sequence number: it is sent with the number of the first message it replaces. And it must carry 43=Y together with 122=OrigSendingTime, or a strict counterparty will reject it.
After a gap fill you still owe yourself a reconciliation: ask the broker for order status and compare it with what you believe is open.
Running a whole day in a millisecond
Here is the payoff of injecting the clock and the transport. This scenario plays the broker, moves time forward by hand and prints both directions:
public static class Scenario
{
static DateTime _clock = new DateTime(2026, 9, 23, 8, 0, 0, DateTimeKind.Utc);
static int _brokerSeq = 1;
static KeyValuePair<int, string> F(int tag, string value) { return new KeyValuePair<int, string>(tag, value); }
// Builds a message "from the broker" and feeds it to the session, as the socket reader would.
static void FromBroker(FixSession s, string msgType, int seq, params KeyValuePair<int, string>[] fields)
{
string raw = FixWriter.Build(msgType, "BROKER", "CLIENT1", seq, _clock, fields);
Console.WriteLine(" <- " + raw.Replace('\x01', '|'));
byte[] bytes = Encoding.ASCII.GetBytes(raw);
s.OnMessage(bytes, 0, bytes.Length);
}
static void Tick(FixSession s, int seconds)
{
for (int i = 0; i < seconds; i++) { _clock = _clock.AddSeconds(1); s.OnTimer(); }
}
public static void Main()
{
string path = Path.Combine(Path.GetTempPath(), "client1-broker.seq");
if (File.Exists(path)) File.Delete(path);
var session = new FixSession("CLIENT1", "BROKER", new FileSeqStore(path),
raw => Console.WriteLine(" -> " + raw.Replace('\x01', '|')), () => _clock);
session.ApplicationMessage += (b, o, n) => Console.WriteLine(" ** application message delivered, " + n + " bytes");
session.Disconnected += reason => Console.WriteLine(" !! disconnected: " + reason);
Console.WriteLine("1. Logon");
session.Logon("12345", "secret", 30, true, F(57, "TRADE"), F(50, "COPIER"));
FromBroker(session, "A", _brokerSeq++, F(98, "0"), F(108, "30"), F(141, "Y"));
Console.WriteLine("2. Thirty quiet seconds");
Tick(session, 30);
Console.WriteLine("3. Broker sends TestRequest");
FromBroker(session, "1", _brokerSeq++, F(112, "PING-7"));
Console.WriteLine("4. A fill arrives with MsgSeqNum 5 while we expect 3");
FromBroker(session, "8", 5, F(11, "ORD-0001"), F(150, "F"), F(39, "2"));
Console.WriteLine("5. Broker gap-fills 3..4, then resends the fill");
FromBroker(session, "4", 3, F(43, "Y"), F(122, "20260923-08:00:30.000"), F(123, "Y"), F(36, "5"));
FromBroker(session, "8", 5, F(43, "Y"), F(122, "20260923-08:00:30.000"), F(11, "ORD-0001"), F(150, "F"), F(39, "2"));
Console.WriteLine("6. Broker asks us to resend from 2");
FromBroker(session, "2", 6, F(7, "2"), F(16, "0"));
Console.WriteLine("7. The line goes silent");
Tick(session, 36);
Tick(session, 24);
Console.WriteLine("8. Restart: sequence numbers survive");
var store = new FileSeqStore(path);
Console.WriteLine(" next out = " + store.NextOut + ", next in = " + store.NextIn);
}
}
And the output, exactly as the program prints it:
1. Logon
-> 8=FIX.4.4|9=114|35=A|49=CLIENT1|56=BROKER|34=1|52=20260923-08:00:00.000|57=TRADE|50=COPIER|98=0|108=30|141=Y|553=12345|554=secret|10=185|
<- 8=FIX.4.4|9=74|35=A|49=BROKER|56=CLIENT1|34=1|52=20260923-08:00:00.000|98=0|108=30|141=Y|10=211|
2. Thirty quiet seconds
-> 8=FIX.4.4|9=56|35=0|49=CLIENT1|56=BROKER|34=2|52=20260923-08:00:30.000|10=128|
3. Broker sends TestRequest
<- 8=FIX.4.4|9=67|35=1|49=BROKER|56=CLIENT1|34=2|52=20260923-08:00:30.000|112=PING-7|10=231|
-> 8=FIX.4.4|9=67|35=0|49=CLIENT1|56=BROKER|34=3|52=20260923-08:00:30.000|112=PING-7|10=231|
4. A fill arrives with MsgSeqNum 5 while we expect 3
<- 8=FIX.4.4|9=79|35=8|49=BROKER|56=CLIENT1|34=5|52=20260923-08:00:30.000|11=ORD-0001|150=F|39=2|10=249|
-> 8=FIX.4.4|9=65|35=2|49=CLIENT1|56=BROKER|34=4|52=20260923-08:00:30.000|7=3|16=0|10=001|
5. Broker gap-fills 3..4, then resends the fill
<- 8=FIX.4.4|9=98|35=4|49=BROKER|56=CLIENT1|34=3|52=20260923-08:00:30.000|43=Y|122=20260923-08:00:30.000|123=Y|36=5|10=135|
<- 8=FIX.4.4|9=110|35=8|49=BROKER|56=CLIENT1|34=5|52=20260923-08:00:30.000|43=Y|122=20260923-08:00:30.000|11=ORD-0001|150=F|39=2|10=014|
** application message delivered, 133 bytes
6. Broker asks us to resend from 2
<- 8=FIX.4.4|9=65|35=2|49=BROKER|56=CLIENT1|34=6|52=20260923-08:00:30.000|7=2|16=0|10=002|
-> 8=FIX.4.4|9=98|35=4|49=CLIENT1|56=BROKER|34=2|52=20260923-08:00:30.000|43=Y|122=20260923-08:00:30.000|123=Y|36=5|10=134|
7. The line goes silent
-> 8=FIX.4.4|9=56|35=0|49=CLIENT1|56=BROKER|34=5|52=20260923-08:01:00.000|10=129|
-> 8=FIX.4.4|9=72|35=1|49=CLIENT1|56=BROKER|34=6|52=20260923-08:01:06.000|112=TEST-080106|10=245|
!! disconnected: No reply to TestRequest
8. Restart: sequence numbers survive
next out = 7, next in = 7
Read step 4 twice. The fill with number 5 is received and deliberately not delivered. Only after the broker fills the gap in step 5 does the application see it, once, in order. Step 6 shows our gap fill going out with 34=2, the first number the broker asked for, and 36=5, where we really are.
What this sketch leaves out
It is a teaching session, not a product. A production one also needs a logon timeout, Logout with a reason and a wait for the reply, Reject (35=3) handling, validation of 49, 56 and 52 on inbound messages, a reconnect policy with backoff, and thread safety between the socket reader, the timer and the order sender. QuickFIX/n does all of that, and after writing the 200 lines above you will know exactly what its log is telling you.
If you would rather watch a FIX session than write one, the same lifecycle is visible from the user side in a FIX client: FIX API Terminal has a plain-language walkthrough of how FIX API trading works. For a non-developer colleague, What is FIX API covers the why. And if the question is whether the extra work over a MetaTrader bridge pays off for latency-sensitive strategies, I compared the two routes in FIX API vs MT4 bridge.
Checklist
- Persist both sequence numbers, and save before you send.
- Never process a message that arrives with a number higher than expected.
- Treat a number lower than expected without
43=Yas fatal. - Answer a ResendRequest with a GapFill unless you have a reason to replay, and never replay orders.
- Send the GapFill with the first replaced number,
43=Yand122. - Detect dead lines with TestRequest, do not trust TCP to tell you.
- Reconcile orders and positions after every logon and after every gap.
- Inject the clock and the transport so all of this can be tested without a broker.
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)