← Back to roadmap
Phase 2Week 14~115 min read
0%·0/4

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

Day 01

Header, body, trailer

Tag=value pairs separated by SOH

Docsby FIX Trading Community
FIX Protocol Specification, Version 4.4

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.

Docsby FIX Trading Community
Fiximate

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: 8 BeginString (protocol version), 9 BodyLength, 35 MsgType (D = NewOrderSingle). Then come 49 SenderCompID, 56 TargetCompID, 34 MsgSeqNum and 52 SendingTime (UTC), in any order.
  • Body. 11 ClOrdID (the client's id for this order), 55 Symbol, 54 Side (1 buy, 2 sell), 60 TransactTime, 38 OrderQty, 40 OrdType (1 market, 2 limit), 44 Price, 59 TimeInForce (0 day, 1 GTC, 3 IOC, 4 FOK).
  • Trailer. 10 CheckSum 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.

Recall#header-order

Which FIX fields have a mandated position, and why does that matter for a parser?

Day 2 — BodyLength and CheckSum

Day 02

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 from 35=D| through 59=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, never 10=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).

Drill

Validate BodyLength and CheckSum

Target: 6/6 cases · byte-exact framingNot yet run
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!()
}
Rust
Recall#checksum-weakness

What corruption does the FIX CheckSum miss, and why do engines still validate it?

Day 3 — Parsing without allocating

Day 03

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. The memchr crate's SIMD search, or the auto-vectorized loop you get from position, 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 dense match into 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" becomes 1_012_500 at 4 implied decimals. Reject values that would lose precision, and use checked arithmetic, because a 25-digit price is an attack, not an order.
Drill

Allocation-free FIX field iterator

Target: 6/6 cases · 0 allocations in 1,000 parsesNot yet run
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!()
}
Rust
Recall#flat-map-wrong

Besides allocation, why is parsing a FIX message into a HashMap from tag to value incorrect?

Day 4 — The session layer

Day 04

Logon, heartbeats, resend and gap fill

The part of FIX that outlives every other part

Code
QuickFIX (C++ FIX engine)

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: 98 EncryptMethod (0 = none), 108 HeartBtInt in seconds, optionally 141 ResetSeqNumFlag. The acceptor replies with its own Logon.
  • Heartbeat (0) is sent when nothing else has been sent for HeartBtInt seconds. TestRequest (1) with a 112 TestReqID 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: 7 BeginSeqNo and 16 EndSeqNo (0 means "everything after BeginSeqNo").
  • SequenceReset (4) with 123 GapFillFlag=Y and 36 NewSeqNo 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 43 PossDupFlag=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 122 OrigSendingTime.
Recall#resend-gapfill

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?

Recall#seq-too-low

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

Day 05

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:

  • 11 ClOrdID 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 through 41 OrigClOrdID, so one order accumulates a chain of ClOrdIDs over its life.
  • 37 OrderID 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:

  • 150 ExecType says what just happened: 0 New, F Trade (FIX 4.4 replaced the older partial-fill and fill values with F), 4 Canceled, 5 Replaced, 8 Rejected.
  • 39 OrdStatus says the order's state now: 0 New, 1 Partially filled, 2 Filled, 4 Canceled, 8 Rejected.

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.

Recall#exectype-ordstatus

An order for 100 is resting; 30 trade, then 70 trade. Give ExecType, OrdStatus, LastQty, CumQty and LeavesQty for both ExecutionReports.

Design note#id-mapping

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.

0 chars

Day 6 — Encoding outbound messages fast

Day 06

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 and copy_from_slice it.
  • Integers without formatting machinery. Write digits into a small stack buffer from the end (or use the itoa crate). 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.
Design note#fix-vs-binary

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.

0 chars

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 the 8=FIX.4.4|9=<len>| prefix and returns Ok(Some(total)), Ok(None) for "read more", or an error, rejecting a BodyLength above MAX_BODY_LEN (64 KiB) before anything is buffered. RawMessage::parse then 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, and to_new_order(id, account, instrument, scale) maps it to the same types::NewOrder the binary protocol produces, with the price converted to ticks without floats (an off-tick price is an error). OrderCancelRequest::to_command() gives a Command::Cancel only 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 with InvalidValue.
  • An encoder for every message type, ExecutionReport included: Encoder::encode(&header, &msg, &mut out) for any FixBody. 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 to out, 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). Session takes the current time in nanoseconds as an argument to start, on_message, poll and send, 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) and w14_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 Session doesn't do this: Session::new starts 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 Session does, 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.

Capstone · in your exchange · crates/fix

crates/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.