FIX Protocol
Tag=value, checksums, and the session layer everyone still uses.
Production · Gateway, Protocols, Risk & Resilience
Why this week
Your binary protocol from week 11 is fast and simple, and almost none of your future clients will use it first. The buy side, brokers and every OMS speak FIX: the Financial Information eXchange protocol, a tag=value text format from the 1990s that is still the lingua franca of electronic trading. Venues offer FIX next to their native binary protocols because it's what everyone already has. If you interview for trading infrastructure, someone will ask you how a ResendRequest works.
FIX is slower to parse than fixed binary: fields are found by scanning, numbers are ASCII, and the order of fields is mostly unconstrained. It's still very fast if you parse it the way you parsed ITCH, as borrowed slices with no allocation and no maps. Its session layer (logon, heartbeats, sequence numbers, resend and gap fill) is also the best-specified recovery protocol you'll meet, and the same ideas reappear in every protocol you design later.
Day 1 — Anatomy of a FIX message
Header, body, trailer
Tag=value pairs separated by SOH
Read the message-format section (field delimiter, standard header and trailer, BodyLength and CheckSum rules, repeating groups), the session-protocol section (logon, heartbeat, test request, resend request, sequence reset, logout) and the NewOrderSingle and ExecutionReport definitions. Skip the rest for now.
The searchable FIX dictionary. Keep it open all week: every tag number in this guide can be looked up there with its type, valid values and the messages that use it.
A FIX message is a sequence of tag=value fields, each terminated by SOH (byte 0x01), shown below as |:
8=FIX.4.4|9=127|35=D|49=CLIENT|56=EXCH|34=3|52=20261004-13:30:00.001|
11=ORD-1|55=ACME|54=1|60=20261004-13:30:00.001|38=100|40=2|44=101.25|59=0|10=212|- Standard header. The first three fields are fixed:
8BeginString (protocol version),9BodyLength,35MsgType (D= NewOrderSingle). Then come49SenderCompID,56TargetCompID,34MsgSeqNum and52SendingTime (UTC), in any order. - Body.
11ClOrdID (the client's id for this order),55Symbol,54Side (1buy,2sell),60TransactTime,38OrderQty,40OrdType (1market,2limit),44Price,59TimeInForce (0day,1GTC,3IOC,4FOK). - Trailer.
10CheckSum is always the last field: exactly three digits.
From FIX 5.0 on, the session layer was split into its own spec, FIXT.1.1. Those messages carry 8=FIXT.1.1 and declare the application version separately. FIX 4.4 has both layers in one document, and it's still very widely deployed, which is why the capstone targets it.
Which FIX fields have a mandated position, and why does that matter for a parser?
Day 2 — BodyLength and CheckSum
Framing a text protocol
Drill 1: validate both, and build a message that passes
The two framing fields have exact definitions, and off-by-one bugs here are the classic first FIX bug:
- BodyLength (9) counts bytes starting after the SOH that ends the 9= field, up to and including the SOH just before
10=. In the message above that's from35=D|through59=0|: 127 bytes. - CheckSum (10) is the sum of every byte before
10=(header and body, including all their SOH bytes) modulo 256, written as exactly three digits with leading zeros (10=015, never10=15).
Validation order matters for error messages. Check the header shape first, then the trailer shape, then BodyLength, then CheckSum. Building a message has a chicken-and-egg problem: the length goes before the body. Build the body first, then prepend the header (Day 6 shows how to do that without copying).
Validate BodyLength and CheckSum
pub const SOH: u8 = 0x01;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixError {
/// Does not start with "8=<non-empty><SOH>9=<1+ digits><SOH>".
BadHeader,
/// Does not end with "<SOH>10=" + exactly 3 digits (000..=255) + SOH.
BadTrailer,
BadBodyLength { declared: usize, actual: usize },
BadChecksum { declared: u8, actual: u8 },
}
/// Sum of all bytes, modulo 256.
pub fn checksum(bytes: &[u8]) -> u8 {
todo!()
}
/// Validate one complete message. Check, in this order:
/// header shape, trailer shape, BodyLength, CheckSum.
pub fn validate(msg: &[u8]) -> Result<(), FixError> {
todo!()
}
/// Build "8=<begin><SOH>9=<len><SOH>" + body + "10=<ccc><SOH>".
/// `body` starts with "35=" and ends with SOH.
pub fn frame(begin_string: &str, body: &[u8]) -> Vec<u8> {
todo!()
}
What corruption does the FIX CheckSum miss, and why do engines still validate it?
Day 3 — Parsing without allocating
Borrowed slices, integer tags, fixed-point prices
Drill 2: the field iterator and a NewOrderSingle view
The slow way to parse FIX is to split on SOH into a Vec<String>, split each field on =, and insert into a HashMap<u32, String>. That's dozens of allocations and a hash per field, and it's also wrong (see the check below). The fast way is the week 11 approach adapted to delimiters:
- Iterate fields as
(u32, &[u8]): parse the tag digits into an integer while scanning to=, then find the next SOH. Values stay borrowed from the receive buffer. Finding delimiters is the main cost. Thememchrcrate's SIMD search, or the auto-vectorized loop you get fromposition, scans many bytes per instruction. - Dispatch on the tag with
match. Store the slices you need in locals and ignore the rest. The compiler turns a densematchinto a jump table or a short series of compares. - Check required fields after the loop, with
Options or a bitmask of seen tags. - Convert prices to fixed-point integers, never
f64."101.25"becomes1_012_500at 4 implied decimals. Reject values that would lose precision, and use checked arithmetic, because a 25-digit price is an attack, not an order.
Allocation-free FIX field iterator
pub const SOH: u8 = 0x01;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
/// Tag empty, non-digit, no '=', or > u32::MAX. `at` = offset of the field start.
BadTag { at: usize },
EmptyValue { tag: u32 },
/// Value runs to the end of input with no SOH.
Unterminated { tag: u32 },
MissingField { tag: u32 },
BadValue { tag: u32 },
}
pub struct Fields<'a> {
buf: &'a [u8],
pos: usize,
done: bool,
}
pub fn fields(buf: &[u8]) -> Fields<'_> {
Fields { buf, pos: 0, done: false }
}
impl<'a> Iterator for Fields<'a> {
type Item = Result<(u32, &'a [u8]), ParseError>;
/// One "tag=value<SOH>" per call. After an error: that error once, then None.
fn next(&mut self) -> Option<Self::Item> {
todo!()
}
}
/// Fixed point, 4 implied decimals: b"101.25" -> 1_012_500.
/// Grammar: optional '-', 1+ digits, optionally '.' and 1+ digits. Digits past
/// the 4th decimal are allowed only if they are all '0'. None on anything
/// else, including overflow.
pub fn parse_price(v: &[u8]) -> Option<i64> {
todo!()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Buy,
Sell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NewOrder<'a> {
pub cl_ord_id: &'a [u8], // 11
pub symbol: &'a [u8], // 55
pub side: Side, // 54: '1' Buy, '2' Sell
pub qty: u64, // 38: positive integer
pub price: Option<i64>, // 44: required when 40=2 (limit); None when 40=1 (market)
}
/// Parse a whole NewOrderSingle (header and trailer included; framing was
/// validated already, so ignore 8/9/10 and any tag you don't need).
/// Iterator errors first; then 35 (must be "D"), 11, 55, 54, 38, 40, and
/// 44 for limits: MissingField if absent, BadValue if invalid. Must not allocate.
pub fn parse_new_order(msg: &[u8]) -> Result<NewOrder<'_>, ParseError> {
todo!()
}
Besides allocation, why is parsing a FIX message into a HashMap from tag to value incorrect?
Day 4 — The session layer
Logon, heartbeats, resend and gap fill
The part of FIX that outlives every other part
The reference open-source engine. Find the session class and follow what happens when a message arrives with a sequence number that is too high, too low, or too low with PossDupFlag set. Compare its logic with the rules below. Don't copy its data structures. It allocates freely.
The session-level message types (MsgType in brackets):
- Logon (A) starts the session:
98EncryptMethod (0= none),108HeartBtInt in seconds, optionally141ResetSeqNumFlag. The acceptor replies with its own Logon. - Heartbeat (0) is sent when nothing else has been sent for HeartBtInt seconds. TestRequest (1) with a
112TestReqID is sent when nothing has been received for HeartBtInt plus a grace period, and the peer must answer with a Heartbeat echoing that id. No answer means the connection is dead. - ResendRequest (2) asks for a range:
7BeginSeqNo and16EndSeqNo (0means "everything after BeginSeqNo"). - SequenceReset (4) with
123GapFillFlag=Y and36NewSeqNo says "skip ahead to NewSeqNo". It's used during resends in place of administrative messages that shouldn't be replayed. - Reject (3) is a session-level rejection of a malformed message. Business-level problems use BusinessMessageReject (j) or application rejects.
- Logout (5) ends the session gracefully. Both sides send it.
Sequence numbers (34 MsgSeqNum) are per direction, start at 1, and persist across reconnects within a session's lifetime (typically a trading day). Each side stores its next expected inbound number and its next outbound number. Every outbound message is stored so it can be resent. When a message arrives:
- Equal to expected: process it, then increment.
- Higher than expected: messages were lost. Send a ResendRequest for the gap and queue or process according to your engine's policy.
- Lower than expected without
43PossDupFlag=Y: a serious error. The spec says to log out and disconnect. - Lower than expected with PossDupFlag=Y: a retransmission of something already processed, so ignore it. Retransmissions carry
122OrigSendingTime.
A counterparty sends ResendRequest 7=100, 16=0. Your outbound messages 100–120 were a mix of Heartbeats, TestRequests and ExecutionReports. What do you send back?
Why is a message with a sequence number lower than expected and no PossDupFlag treated as fatal rather than just ignored?
Day 5 — Mapping FIX to your engine
ClOrdID, OrderID and execution reports
Two id spaces and a state machine per order
FIX orders carry the client's identifiers, and your engine has its own:
11ClOrdID is chosen by the client and must be unique per session for the life of the order. A cancel or replace gets a new ClOrdID and points at the order through41OrigClOrdID, so one order accumulates a chain of ClOrdIDs over its life.37OrderID is assigned by the exchange and never changes. The gateway owns the mapping from (session, ClOrdID) to OrderID and must reject a duplicate ClOrdID.
Every state change goes back as an ExecutionReport (8) with two status fields that are easy to confuse:
150ExecType says what just happened:0New,FTrade (FIX 4.4 replaced the older partial-fill and fill values withF),4Canceled,5Replaced,8Rejected.39OrdStatus says the order's state now:0New,1Partially filled,2Filled,4Canceled,8Rejected.
Fills also carry 32 LastQty and 31 LastPx (this fill), 14 CumQty and 151 LeavesQty (running totals, where CumQty + LeavesQty = OrderQty while the order is open), and 6 AvgPx. A rejected cancel is an OrderCancelReject (9), not an ExecutionReport.
An order for 100 is resting; 30 trade, then 70 trade. Give ExecType, OrdStatus, LastQty, CumQty and LeavesQty for both ExecutionReports.
Design the gateway's id mapping for FIX: the data structure from (session, ClOrdID) to OrderID, how cancel/replace chains are tracked through OrigClOrdID, when entries are freed, and what happens to the map when a session reconnects mid-day with sequence numbers intact. Keep it allocation-free on the hot path.
Day 6 — Encoding outbound messages fast
Writing FIX without a String
Templates, back-filled headers and a running checksum
The outbound path matters as much as parsing: every fill produces an ExecutionReport for at least one session. Techniques that keep it allocation-free:
- Body first, header back-filled. Reserve 32 bytes at the front of a reusable buffer. Write the body after it, then write
8=FIX.4.4|9=<len>|backwards so it ends exactly where the body starts, and send from the header's first byte. No copy, no second pass. - Per-session constant prefix.
35=8|49=EXCH|56=CLIENT|never changes for a session. Precompute it once at logon andcopy_from_sliceit. - Integers without formatting machinery. Write digits into a small stack buffer from the end (or use the
itoacrate).format!allocates and goes through the formatting traits. - Cached timestamps. SendingTime only changes in its last digits within a millisecond. Cache the formatted prefix (
YYYYMMDD-HH:MM:SS.) and rewrite it only when the second changes. - Running checksum. Sum bytes as you write them, or sum the finished buffer once. A byte sum over 200 bytes vectorizes to a handful of instructions.
Your exchange offers both FIX and the native binary protocol from week 11. Which clients should use which, and what would you tell a market maker who insists on FIX? Put rough numbers on the extra parse and encode cost per order and compare them with the rest of the round trip from week 13.
Capstone
Build crates/fix, a sans-IO FIX 4.4 library: it never touches a socket or a clock, so the week 13 gateway can drive it as a second session protocol and the tests can drive it with byte buffers and a fake time. The week's tests exercise the crate on its own.
- Framing over the TCP stream, using the mandated header order.
message_len(buf)reads only the8=FIX.4.4|9=<len>|prefix and returnsOk(Some(total)),Ok(None)for "read more", or an error, rejecting a BodyLength aboveMAX_BODY_LEN(64 KiB) before anything is buffered.RawMessage::parsethen checks BodyLength and CheckSum and parses the standard header (35 first, then 49, 56, 34 and 52 required). - Zero-allocation decoders for NewOrderSingle (D) and OrderCancelRequest (F):
NewOrderSingle::decode(&raw)returns a struct that borrows the buffer, andto_new_order(id, account, instrument, scale)maps it to the sametypes::NewOrderthe binary protocol produces, with the price converted to ticks without floats (an off-tick price is an error).OrderCancelRequest::to_command()gives aCommand::Cancelonly when OrderID (37) is present; otherwise the gateway resolves OrigClOrdID itself. Repeating groups aren't supported: the decoders skip tags they don't read, so a Parties group is ignored rather than misread, and unsupported codes (a stop OrdType, a sell-short Side) are rejected withInvalidValue. - An encoder for every message type, ExecutionReport included:
Encoder::encode(&header, &msg, &mut out)for anyFixBody. The repo takes the simpler of Day 6's options: it writes the body into a reusable scratch buffer, then appends prefix, body and trailer toout, so there's one copy of the body and no allocation once the buffers are warm. Back-filling the header and a per-session prefix are the next steps if encoding ever shows up in a profile. OrderCancelReject (9) isn't in the crate. - A session layer as a pure state machine (like the week 13 drill, extended).
Sessiontakes the current time in nanoseconds as an argument tostart,on_message,pollandsend, and appends what it needs to send to an output buffer. It handles Logon, Heartbeat/TestRequest timing, inbound sequence checks (a gap sends one ResendRequest(expected, 0) and drops the early message, since the peer will resend it), SequenceReset in both modes, and Logout. It keeps no store of sent messages, so a peer's ResendRequest is answered with a single SequenceReset-GapFill (PossDupFlag=Y) over the whole range. A production venue resends its ExecutionReports from the journal instead. - Conformance tests written from the spec's session scenarios, with two
Sessions wired back to back:w14_session_sequence_gaps_and_too_low(gap → one ResendRequest, gap fill closes it, garbled messages don't consume a number, too low without PossDup → Logout and disconnect) andw14_session_logon_heartbeats_and_test_requests(heartbeats when idle, TestRequest after HeartBtInt + 20%, unanswered → Logout and disconnect).
Pitfalls:
- Persisting sequence numbers. If the gateway restarts mid-day and resets to 1, every counterparty sees a low sequence number and logs you out. Store both directions' numbers durably (the journal from week 9 is a natural home) and restore them on restart. The repo's
Sessiondoesn't do this:Session::newstarts both at 1, and only ResetSeqNumFlag resets them. - The outbound store is on the hot path. Every message you send must be retrievable for a real resend. If you add one, append to a ring or log, not to a
HashMap<u64, Vec<u8>>. - Time in the session layer. Pass the time in, as the repo's
Sessiondoes, so heartbeat logic is testable without sleeping. - Floats. A single
str::parse::<f64>()on a price is a correctness bug, not just a slow path.
How to think about the parse target: w14.parse_nos takes a 162-byte limit NewOrderSingle through RawMessage::parse (BodyLength, CheckSum, header), NewOrderSingle::decode and to_new_order, and the target is 250 ns at p50, about 1.5 ns per byte. That rules out allocation, maps and per-field UTF-8 validation, and is comfortable for scanning passes with integer tag dispatch. A separate checksum pass is fine: a byte sum vectorizes, and the reference does one. If you're over, profile before micro-optimizing. The usual culprits are a HashMap, str::from_utf8 on every value, or a float parse.
crates/fixcrates/fix — a FIX 4.4 front door
Zero-allocation parse and encode for NewOrderSingle, OrderCancelRequest and ExecutionReport, plus a session layer with sequence checks, resend handling and heartbeats.
cargo xtask test --week 14 # tests named w14_*
cargo xtask bench --week 14 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 14 tests pass w14_*
- Parse NewOrderSingle w14.parse_nostarget p50 ≤ 250 ns
Feeds into
No later week lists FIX as a hard prerequisite: the rest of the curriculum drives the exchange through the binary protocol. The fix crate stays in the workspace, though. The week 24 end-to-end build is the natural place to put it in front of the engine and run it under load next to the binary sessions, and the session-layer ideas (sequence numbers, resend, gap fill) come back in week 20's replication and week 49's system-design practice.