BLOG POST
FIX Protocol Demystified: Building Real-Time Stock Quote Distribution

If you've spent any meaningful time in fintech infrastructure - and I mean the actual plumbing, not the shiny React dashboards that PMs get excited about - you've inevitably bumped into FIX. The Financial Information eXchange protocol is one of those technologies that runs quietly under the surface of every electronic market on earth, processing trillions of dollars in daily volume, while most developers have never seen a raw FIX message in their lives.
I've been building distributed systems across telecom and fintech for long enough to know that protocols are either elegant or they're survivors. FIX is the latter. It's not pretty. It's ASCII-encoded tag-value pairs separated by a non-printable control character, born in 1992 when Fidelity needed machine-readable trade confirmations from Salomon Brothers and someone decided phone calls weren't cutting it anymore. Twenty-five years later, it's still here, still ASCII, and still the only language that every broker, exchange, dark pool, and ECN on the planet speaks fluently.
This post is the guide I wish I'd had when I first had to build a real-time market data distribution system. We're going to cover FIX from the wire format up through session management, market data semantics, a full working implementation in Go using QuickFIX/Go, the XML-to-Go code generation pipeline that makes it all type-safe, and finally how to actually deploy and run this thing in production on Linux with systemd - because that's where it lives in the real world, not in some Kubernetes cluster that your DevOps team just discovered at KubeCon.
I'm writing this in Go because it's the right tool for this job. My primary stack is Go for backend services, with PostgreSQL and RabbitMQ for messaging. For a FIX engine - a long-running, concurrent, network-intensive service that needs to handle hundreds of TCP sessions without breaking a sweat - Go is almost embarrassingly well-suited. Go 1.9 dropped in August with parallel compilation and monotonic time support (that last one actually matters when you're timestamping financial messages), and QuickFIX/Go v0.6.0 is stable enough that I'm comfortable recommending it for production workloads.
Let's get into it.
What FIX Actually Is (And Why It Refuses to Die)
FIX - Financial Information eXchange - was created in 1992 by Robert Lamoureux and Chris Morstatt at Fidelity Investments. The motivation was painfully practical: they were tired of negotiating trade details over the phone with Salomon Brothers and wanted a structured electronic format instead. What started as a bilateral messaging spec for equity trades has metastasized into the universal protocol for electronic trading across every asset class, in every major market, globally.
The FIX Trading Community (formerly FIX Protocol Limited) now has over 280 member firms - exchanges, broker-dealers, buy-side firms, technology vendors - and the protocol covers everything from pre-trade indications through order execution to post-trade allocation and settlement. If two financial institutions need to talk to each other electronically, FIX is the default language.
Version history (what you'll actually encounter in production)
Here's the version landscape as of late 2017:
| Version | Year | Status in Production |
|---|---|---|
| FIX 4.0 | 1996 | Legacy. You'll see it at old Asian exchanges. |
| FIX 4.1 | 1997 | Legacy. |
| FIX 4.2 | 2000 | THE dominant version. Most equity trading still runs on 4.2. |
| FIX 4.3 | 2001 | Uncommon. Transitional release. |
| FIX 4.4 | 2003 | Second most deployed. Multi-asset desks love it. |
| FIX 5.0 | 2006 | Architectural shift (FIXT 1.1 session layer split). |
| FIX 5.0 SP1 | 2009 | Extension packs model introduced. |
| FIX 5.0 SP2 | 2011 | Current latest. 200+ extension packs. Still gaining adoption. |
The big deal with FIX 5.0 was the session/application layer separation. In FIX 4.x, the BeginString field (tag 8) determines both the session protocol and the application message version - they're coupled. FIX 5.0 introduced FIXT 1.1 as an independent session transport layer, and application messages are versioned separately via the ApplVerID field (tag 1128). This means a single TCP session running FIXT 1.1 can carry FIX 4.4 messages from your legacy order management system and FIX 5.0 SP2 messages from your new algo engine simultaneously.
In practice? Most of the world is still on FIX 4.2 or 4.4. That's what you'll build against. That's what I'm building against.
The Wire Format: Tag-Value Encoding
FIX uses a dead-simple encoding: ASCII text, tag=value pairs, separated by the SOH character (ASCII 0x01). SOH is a non-printable control character, so in documentation and logs it's typically rendered as | (pipe).
Here's a raw Logon message:
8=FIX.4.4|9=84|35=A|49=BROKER|56=CLIENT|34=1|52=20170615-14:30:00.000|98=0|108=30|10=157|Breaking that down:
| Tag | Name | Value | Meaning |
|---|---|---|---|
| 8 | BeginString | FIX.4.4 | Protocol version |
| 9 | BodyLength | 84 | Byte count of body |
| 35 | MsgType | A | Logon message |
| 49 | SenderCompID | BROKER | Who's sending |
| 56 | TargetCompID | CLIENT | Who's receiving |
| 34 | MsgSeqNum | 1 | Sequence number |
| 52 | SendingTime | 20170615-14:30:00.000 | UTC timestamp |
| 98 | EncryptMethod | 0 | None (always 0 in practice) |
| 108 | HeartBtInt | 30 | 30 second heartbeat interval |
| 10 | CheckSum | 157 | Integrity check |
That's it. That's the encoding. No length-prefixed binary frames. No schema negotiation. No compression. Just ASCII key-value pairs separated by a control character, sent over a raw TCP socket.
If you're coming from the world of Protocol Buffers, MessagePack, or even JSON, your first reaction is probably "this is insane." And you're not wrong. Parsing ASCII tag-value pairs is slower than reading fixed-position binary fields, the messages are larger than they need to be, and there's no built-in schema evolution mechanism beyond "add new tags and hope the counterparty doesn't reject them."
But here's the thing - when you're debugging a production issue at 2 AM and you can literally tcpdump the wire and read the messages with your eyes, you start to appreciate the value of human-readable protocols. I've debugged more FIX connectivity issues with grep and awk than I care to admit, and every single time I'm grateful that some engineer in 1992 chose ASCII over binary.
That's it. That's the encoding. No length-prefixed binary frames. No schema negotiation. No compression. Just ASCII key-value pairs separated by a control character, sent over a raw TCP socket.
If you're coming from the world of Protocol Buffers, MessagePack, or even JSON, your first reaction is probably "this is insane." And you're not wrong. Parsing ASCII tag-value pairs is slower than reading fixed-position binary fields, the messages are larger than they need to be, and there's no built-in schema evolution mechanism beyond "add new tags and hope the counterparty doesn't reject them."
But here's the thing - when you're debugging a production issue at 2 AM and you can literally tcpdump the wire and read the messages with your eyes, you start to appreciate the value of human-readable protocols. I've debugged more FIX connectivity issues with grep and awk than I care to admit, and every single time I'm grateful that some engineer in 1992 chose ASCII over binary.
FIX Message Anatomy: Header, Body, Trailer
Every FIX message has exactly three sections, and the ordering rules within them are non-negotiable.
Header
The header MUST begin with these three fields in exactly this order:
BeginString(tag 8) - protocol version identifier. Always first.BodyLength(tag 9) - character count from start of tag 35 through the last SOH before tag 10. Always second.MsgType(tag 35) - identifies what kind of message this is. Always third.
After those three anchors, the rest of the header fields follow:
| Tag | Name | Required | Description |
|---|---|---|---|
| 49 | SenderCompID | Y | Session initiator identifier |
| 56 | TargetCompID | Y | Session target identifier |
| 34 | MsgSeqNum | Y | Message sequence number |
| 52 | SendingTime | Y | UTC timestamp (YYYYMMDD-HH:MM:SS.sss) |
| 43 | PossDupFlag | N | Y if this is a resend |
| 97 | PossResend | N | Y if this might be a duplicate |
Body
The body contains message-specific fields. Their ordering is generally flexible - the spec recommends a canonical order but doesn't enforce it, with one critical exception: repeating groups. A repeating group starts with a counter field (e.g., NoMDEntryTypes, tag 267) whose integer value tells the parser how many group instances follow. Each instance contains the same set of fields, and the first field of each instance is the delimiter that signals a new group entry. Get this wrong and your parser will consume fields from the next group instance or the surrounding message body. It's the single most common source of FIX parsing bugs.
Trailer
The trailer is exactly one field:
| Tag | Name | Required | Description |
|---|---|---|---|
| 10 | CheckSum | Y | Sum of all preceding bytes mod 256, zero-padded to 3 digits |
The checksum computation: take every byte from 8= all the way through the SOH character that precedes 10=, sum their ASCII values, take modulo 256, and format as a three-digit zero-padded decimal string. So 000, 042, 157, etc.
This is a basic integrity check - it catches garbled TCP segments, truncated messages, and encoding mistakes. It is not a cryptographic hash. It is not a signature. If you need actual message integrity guarantees, you're looking at TLS (the FIXS standard) or application-level signing.
Message type taxonomy
FIX messages split into two categories, and understanding this distinction is critical for session management:
Session-level (administrative) messages manage the connection itself:
| MsgType | Name | Purpose |
|---|---|---|
| A | Logon | Authenticate and start session |
| 5 | Logout | Graceful session termination |
| 0 | Heartbeat | Keep-alive / TestRequest response |
| 1 | TestRequest | Probe if counterparty is alive |
| 2 | ResendRequest | Request retransmission of missed messages |
| 3 | Reject | Session-level message rejection |
| 4 | SequenceReset | Reset sequence numbers (GapFill or Reset mode) |
Application-level messages carry business content:
| MsgType | Name | Purpose |
|---|---|---|
| D | NewOrderSingle | Submit an order |
| 8 | ExecutionReport | Order acknowledgment / fill / cancel |
| V | MarketDataRequest | Subscribe to market data |
| W | MarketDataSnapshotFullRefresh | Complete book state for one instrument |
| X | MarketDataIncrementalRefresh | Delta updates (multi-instrument) |
| Y | MarketDataRequestReject | Subscription rejection |
The key difference: session messages are never retransmitted during gap-fill recovery. Application messages are. This is why they're tracked separately - when you replay messages after a sequence gap, you skip admin messages and replace them with SequenceReset-GapFill markers.
Session Layer: Sequence Numbers, Heartbeats, and Recovery
This is where FIX earns its reliability reputation, and it's also where most implementation bugs hide.
Sequence numbers: the backbone of reliable delivery
Each side of a FIX session maintains two counters:
- Outbound sequence number: what I tag my next outgoing message with
- Expected inbound sequence number: what I expect the next incoming message to carry
Both counters start at 1 at session initiation (start-of-day) and increment monotonically. They persist across TCP reconnections within the same logical trading day - if your TCP socket drops and you reconnect, you resume from where you left off, not from 1.
The gap detection algorithm:
incoming.MsgSeqNum == expected → process message, increment expected
incoming.MsgSeqNum > expected → GAP DETECTED: send ResendRequest(BeginSeqNo=expected, EndSeqNo=0)
incoming.MsgSeqNum < expected → possible duplicate or error, check PossDupFlagSetting EndSeqNo=0 on a ResendRequest means "send me everything from BeginSeqNo to your current sequence." The sender then replays stored messages with PossDupFlag=Y, and for any admin messages that were in the gap, it sends SequenceReset-GapFill (MsgType=4, GapFillFlag (123)=Y) with NewSeqNo (tag 36) set past the range of skipped messages.
There's also SequenceReset-Reset (GapFillFlag=N), which is the nuclear option - it forcibly resets the expected sequence number to whatever value you specify. This should only be used for disaster recovery scenarios when the message store is corrupted or lost. If you're using this in normal operations, something has gone very wrong.
Heartbeat mechanism
The heartbeat interval is agreed during the Logon handshake via HeartBtInt (tag 108). Typically 30 seconds.
The protocol:
- If no message sent within HeartBtInt seconds → send Heartbeat (35=0)
- If no message received within HeartBtInt + reasonable_delay → send TestRequest (35=1) with TestReqID (112) = some unique string
- Counterparty MUST respond with Heartbeat containing matching TestReqID
- If no response within another HeartBtInt → connection is dead, disconnect
The "reasonable_delay" is implementation-specific. Most engines use HeartBtInt × 1.2 or HeartBtInt + 2 seconds. In practice, if you're not receiving heartbeats and your TCP socket hasn't errored out, the counterparty's application has likely hung while the OS TCP stack keeps the socket alive. The TestRequest mechanism detects this.
Visual: FIX session lifecycle
Here's how a typical FIX session lifecycle looks in practice:

The beauty of this design is that neither side needs to know why messages were lost - TCP drop, network partition, application restart - the recovery mechanism is the same: detect the gap, request retransmission, resume. Twenty-five years old and it still works reliably.
Market Data Messages: The Core of Quote Distribution
For building a stock quote distribution system, three message types are the entire universe:
MarketDataRequest (MsgType = V)
This is how a client says "I want data." The critical fields:
| Tag | Name | Type | Values & Meaning |
|---|---|---|---|
| 262 | MDReqID | STRING | Unique request ID (for correlation & unsub) |
| 263 | SubscriptionRequestType | CHAR | 0=Snapshot, 1=Snapshot+Updates, 2=Unsubscribe |
| 264 | MarketDepth | INT | 0=Full book, 1=Top of book, N=N levels |
| 265 | MDUpdateType | INT | 0=Full refresh each update, 1=Incremental |
| 266 | AggregatedBook | BOOL | Y=Aggregate across venues, N=Per-venue |
Plus two repeating groups:
NoMDEntryTypes (tag 267) - what data types the client wants:
MDEntryType (269) Values:
0 = Bid
1 = Offer (Ask)
2 = Trade
4 = Opening Price
5 = Closing Price
6 = Settlement Price
7 = Trading Session High Price
8 = Trading Session Low Price
9 = VWAP
B = Trade VolumeNoRelatedSym (tag 146) - what instruments:
Symbol (55) = "AAPL"
SecurityID (48) = "US0378331005" (optional ISIN)
SecurityExchange (207) = "XNAS" (optional MIC code)Here's a concrete example - subscribing to top-of-book bids and offers for AAPL and MSFT with incremental updates:
8=FIX.4.4|9=XXX|35=V|49=CLIENT|56=MKTDATA|34=5|52=20171015-09:30:00.000|
262=MDR-20171015-001|263=1|264=1|265=1|
267=2|269=0|269=1|
146=2|55=AAPL|55=MSFT|
10=XXX|To unsubscribe later, send another MarketDataRequest with SubscriptionRequestType=2 and the same MDReqID:
8=FIX.4.4|9=XXX|35=V|49=CLIENT|56=MKTDATA|34=12|52=20171015-10:15:00.000|
262=MDR-20171015-001|263=2|264=1|
267=2|269=0|269=1|
146=2|55=AAPL|55=MSFT|
10=XXX|MarketDataSnapshotFullRefresh (MsgType = W)
The server responds with a complete picture of the current book for one instrument. One instrument per message - if the client subscribed to AAPL and MSFT, they get two separate W messages.
8=FIX.4.4|9=XXX|35=W|49=MKTDATA|56=CLIENT|34=6|52=20171015-09:30:01.123|
262=MDR-20171015-001|55=AAPL|
268=4|
269=0|270=155.25|271=800| (Bid: $155.25, 800 shares)
269=0|270=155.20|271=1200| (Bid: $155.20, 1200 shares - level 2 if depth > 1)
269=1|270=155.28|271=500| (Offer: $155.28, 500 shares)
269=1|270=155.32|271=900| (Offer: $155.32, 900 shares)
10=XXX|Key fields per entry:
| Tag | Name | Description |
|---|---|---|
| 269 | MDEntryType | 0=Bid, 1=Offer, 2=Trade, etc. |
| 270 | MDEntryPx | Price |
| 271 | MDEntrySize | Quantity |
| 272 | MDEntryDate | Date (optional) |
| 273 | MDEntryTime | Time (optional) |
| 278 | MDEntryID | Unique entry identifier |
Processing is dead simple: when you receive a W message, blow away your local book state for that instrument and replace it entirely. No merge logic. No conflict resolution. Just overwrite.
MarketDataIncrementalRefresh (MsgType = X)
This is where it gets interesting - and where most of the bugs live.
Unlike the full refresh, an incremental refresh sends only what changed, and a single X message can contain updates for multiple instruments. Each entry carries an MDUpdateAction (tag 279):
MDUpdateAction Values:
0 = New (add this entry to the book)
1 = Change (modify the existing entry identified by MDEntryID)
2 = Delete (remove the entry identified by MDEntryID)Example - AAPL bid price improved, MSFT offer size changed:
8=FIX.4.4|9=XXX|35=X|49=MKTDATA|56=CLIENT|34=7|52=20171015-09:30:01.456|
262=MDR-20171015-001|
268=2|
279=1|278=BID-AAPL-001|55=AAPL|269=0|270=155.30|271=600|
279=1|278=ASK-MSFT-003|55=MSFT|269=1|270=78.55|271=400|
10=XXX|The client MUST maintain a local order book per instrument and apply these operations correctly. If you miss a message (sequence gap), apply them out of order, or misidentify an MDEntryID, your local book is corrupted and silently wrong. There's no checksumming on book state. You won't know your book is wrong until your algo makes a trade at a price that doesn't exist.
The recovery strategy: when you detect inconsistency (or on reconnection), request a fresh full snapshot (SubscriptionRequestType=0) to reset to a known-good baseline, then resume incrementals.
The fundamental tradeoff

Architecture: Designing a Market Data Distribution System
Here's the architecture I use for a FIX-based market data distribution system. This is not theoretical - this is what I'd deploy for a mid-tier broker-dealer or trading firm that needs to distribute quotes from exchange feeds to internal consumers.

Key design decisions:
The internal message bus is not FIX. FIX is a session protocol designed for point-to-point communication between firms. Internally, I use RabbitMQ for the fan-out. It's battle-tested, the AMQP model maps cleanly to market data distribution (topic exchanges with routing keys like md.equity.AAPL.quote), and operationally it's one of the most predictable pieces of infrastructure I've ever run - which is exactly what you want when your message bus is sitting between an exchange feed and 200 client sessions. The market data gateway normalizes incoming FIX messages into a canonical internal format (protobuf or flat JSON depending on the consumer), publishes to RabbitMQ topic exchanges, and the FIX acceptors consume from bound queues and translate back to FIX for external clients.
One acceptor per client tier, not per client. A single QuickFIX/Go acceptor can handle hundreds of concurrent FIX sessions. You shard by client tier (latency-sensitive vs. best-effort, internal vs. external) for different SLA characteristics, not because of connection limits.
Separation of concerns. The FIX protocol handling (session management, sequence numbers, heartbeats) is entirely delegated to QuickFIX/Go. My application code only sees typed Go structs representing business messages. The feed handler does normalization and enrichment (adding derived fields, converting identifiers, applying entitlements). The message bus handles fan-out. Each component can be deployed, scaled, and upgraded independently.
Why exchanges moved past FIX for raw market data
If FIX is the lingua franca, why don't exchanges just use it for everything?
Because tag-value FIX over TCP has fundamental scaling problems for high-frequency market data:
Point-to-point TCP - each subscriber needs a dedicated TCP connection. When you have 10,000 subscribers all wanting AAPL quotes, that's 10,000 TCP sessions the exchange has to maintain. Compare this to UDP multicast where one packet reaches all subscribers on the same multicast group.
ASCII encoding - string parsing adds measurable latency. When CME moved from FIX to SBE (Simple Binary Encoding), they got message sizes down by roughly 50% and parsing time down by orders of magnitude. SBE uses fixed-position binary fields - you know that the bid price is always at byte offset 32, so you just cast a pointer. No scanning for delimiters.
TCP head-of-line blocking - if one TCP segment is lost, all subsequent data is buffered in the kernel until retransmission completes. For a market data stream doing 100,000 messages/second, a single lost packet can stall hundreds of messages.
Currently, the landscape looks like this:
| Exchange / Protocol | Encoding | Transport |
|---|---|---|
| CME MDP 3.0 | SBE | UDP Multicast |
| NASDAQ TotalView-ITCH 5.0 | ITCH | UDP Multicast / TCP |
| Moscow Exchange SPECTRA | FAST 1.2 | UDP Multicast |
| Hong Kong (HKEx OMD) | FAST 1.2 | UDP Multicast |
| NYSE Arca / Pillar | XDP | UDP Multicast |
| LSE (LSEG) | MITCH | UDP Multicast |
FIX over TCP remains dominant for order routing (NewOrderSingle, ExecutionReport) and for market data distribution at moderate rates - broker-to-client feeds, aggregation platforms, internal distribution within firms. And that's exactly the use case we're building for.
QuickFIX/Go: The Engine Under the Hood
QuickFIX was born in 2002, created by Oren Miller with a ThoughtWorks team and Jim Downs of Connamara Systems. The original was C++. QuickFIX/J brought it to Java. QuickFIX/n to .NET. And QuickFIX/Go - maintained by Connamara - is a 100% pure Go implementation.
Current release: v0.6.0 (August 2017). Requires Go 1.6+.
The GitHub repo: github.com/quickfixgo/quickfix
All QuickFIX implementations share the same architectural DNA:

The Application interface
This is the contract every QuickFIX/Go application must fulfill:
type Application interface {
// Called when a new session is created (at config parse time, not connection time)
OnCreate(sessionID SessionID)
// Called when a session successfully logs on
OnLogon(sessionID SessionID)
// Called when a session logs out or disconnects
OnLogout(sessionID SessionID)
// Called before sending an admin (session-level) message
// Use this to inject credentials into Logon messages
ToAdmin(message *Message, sessionID SessionID)
// Called before sending an application message
// Return error to prevent sending
ToApp(message *Message, sessionID SessionID) error
// Called when an admin message is received
// Return MessageRejectError to reject
FromAdmin(message *Message, sessionID SessionID) MessageRejectError
// Called when an application message is received
// THIS IS WHERE YOUR BUSINESS LOGIC LIVES
FromApp(message *Message, sessionID SessionID) MessageRejectError
}The SessionID struct identifies a session uniquely:
type SessionID struct {
BeginString string // "FIX.4.4"
SenderCompID string // "MKTDATA"
TargetCompID string // "CLIENT_A"
Qualifier string // Optional - for multiple sessions to same counterparty
}Configuration
QuickFIX/Go uses INI-style config files. [DEFAULT] provides inherited settings; each [SESSION] block defines one FIX session and can override defaults.
Acceptor (server) config - config/acceptor.cfg:
[DEFAULT]
ConnectionType=acceptor
SocketAcceptPort=5001
FileStorePath=store
FileLogPath=log
SenderCompID=MKTDATA
ResetOnLogon=Y
DataDictionary=spec/FIX44.xml
[SESSION]
BeginString=FIX.4.4
TargetCompID=CLIENT_A
[SESSION]
BeginString=FIX.4.4
TargetCompID=CLIENT_B
[SESSION]
BeginString=FIX.4.4
TargetCompID=ALGO_ENGINEInitiator (client) config - config/initiator.cfg:
[DEFAULT]
ConnectionType=initiator
FileStorePath=store
FileLogPath=log
SenderCompID=CLIENT_A
HeartBtInt=30
ReconnectInterval=60
DataDictionary=spec/FIX44.xml
[SESSION]
BeginString=FIX.4.4
TargetCompID=MKTDATA
SocketConnectHost=10.0.1.50
SocketConnectPort=5001Key settings worth knowing:
| Setting | Description | Default |
|---|---|---|
| DataDictionary | Path to FIX XML spec for validation | (none) |
| HeartBtInt | Heartbeat interval in seconds (initiator) | 30 |
| ReconnectInterval | Seconds between reconnection attempts | 30 |
| ResetOnLogon | Reset sequence numbers each Logon | N |
| FileStorePath | Directory for persistent session state | (required) |
| FileLogPath | Directory for message logs | (none) |
| SocketAcceptPort | TCP port to listen on (acceptor) | (required) |
| SocketConnectHost | Remote host to connect to (initiator) | (required) |
| SocketConnectPort | Remote port to connect to (initiator) | (required) |
| StartTime | Daily session start time (UTC) | (none) |
| EndTime | Daily session end time (UTC) | (none) |
ResetOnLogon=Y is useful during development and for sessions where you don't need cross-reconnection message recovery (e.g., market data subscriptions that will be re-established anyway). In production order routing, you almost always want ResetOnLogon=N so that missed execution reports are recovered after reconnection.
XML to Go: The Code Generation Pipeline
This is one of the most powerful features of QuickFIX/Go and one of the least documented. The generate-fix tool reads FIX XML data dictionary files and produces type-safe Go packages for every message, field, component, and enum in the specification. This is how you go from working with raw tag=value pairs to working with marketdatarequest.MarketDataRequest structs that have typed getters and setters.
FIX data dictionary structure
The XML spec files live in the spec/ directory of the QuickFIX repository. Let's walk through the structure of FIX44.xml:
<?xml version="1.0" encoding="UTF-8"?>
<fix major="4" minor="4" servicepack="0" type="FIX">
<!-- Standard header fields (present on every message) -->
<header>
<field name="BeginString" required="Y" />
<field name="BodyLength" required="Y" />
<field name="MsgType" required="Y" />
<field name="SenderCompID" required="Y" />
<field name="TargetCompID" required="Y" />
<field name="MsgSeqNum" required="Y" />
<field name="SendingTime" required="Y" />
<!-- ... more header fields ... -->
</header>
<!-- Standard trailer (just CheckSum) -->
<trailer>
<field name="CheckSum" required="Y" />
</trailer>
<!-- All message definitions -->
<messages>
<!-- ... -->
</messages>
<!-- Reusable field groups (components) -->
<components>
<!-- ... -->
</components>
<!-- Every field definition with type and enums -->
<fields>
<!-- ... -->
</fields>
</fix>Fields section: the atomic units
Every FIX field is defined with a tag number, name, type, and optionally enumerated values:
<fields>
<!-- Simple fields -->
<field number="55" name="Symbol" type="STRING" />
<field number="262" name="MDReqID" type="STRING" />
<field number="264" name="MarketDepth" type="INT" />
<field number="270" name="MDEntryPx" type="PRICE" />
<field number="271" name="MDEntrySize" type="QTY" />
<field number="278" name="MDEntryID" type="STRING" />
<!-- Fields with enumerated values -->
<field number="263" name="SubscriptionRequestType" type="CHAR">
<value enum="0" description="SNAPSHOT" />
<value enum="1" description="SNAPSHOT_PLUS_UPDATES" />
<value enum="2" description="DISABLE_PREVIOUS_SNAPSHOT_PLUS_UPDATE_REQUEST" />
</field>
<field number="269" name="MDEntryType" type="CHAR">
<value enum="0" description="BID" />
<value enum="1" description="OFFER" />
<value enum="2" description="TRADE" />
<value enum="4" description="OPENING_PRICE" />
<value enum="5" description="CLOSING_PRICE" />
<value enum="6" description="SETTLEMENT_PRICE" />
<value enum="7" description="TRADING_SESSION_HIGH_PRICE" />
<value enum="8" description="TRADING_SESSION_LOW_PRICE" />
<value enum="9" description="VWAP" />
<value enum="B" description="TRADE_VOLUME" />
</field>
<field number="279" name="MDUpdateAction" type="CHAR">
<value enum="0" description="NEW" />
<value enum="1" description="CHANGE" />
<value enum="2" description="DELETE" />
</field>
</fields>Components section: reusable building blocks
The Instrument component is the most heavily reused component in FIX - it bundles all instrument-identifying fields into a single referenceable block:
<components>
<component name="Instrument">
<field name="Symbol" required="N" />
<field name="SecurityID" required="N" />
<field name="SecurityIDSource" required="N" />
<field name="SecurityType" required="N" />
<field name="MaturityMonthYear" required="N" />
<field name="MaturityDate" required="N" />
<field name="StrikePrice" required="N" />
<field name="StrikeCurrency" required="N" />
<field name="OptAttribute" required="N" />
<!-- ... dozens more fields ... -->
</component>
</components>When a message references <component name="Instrument">, all of these fields become available on that message or repeating group entry.
Messages section: where it comes together
Here's the MarketDataRequest message definition:
<messages>
<message name="MarketDataRequest" msgtype="V" msgcat="app">
<field name="MDReqID" required="Y" />
<field name="SubscriptionRequestType" required="Y" />
<field name="MarketDepth" required="Y" />
<field name="MDUpdateType" required="N" />
<field name="AggregatedBook" required="N" />
<group name="NoMDEntryTypes" required="Y">
<field name="MDEntryType" required="Y" />
</group>
<group name="NoRelatedSym" required="Y">
<component name="Instrument" required="Y" />
</group>
</message>
</messages>The <group> elements define repeating groups. The name attribute matches the counter field - NoMDEntryTypes is tag 267, NoRelatedSym is tag 146. The fields inside are what each group repetition contains.
Running the generator
The generate-fix tool lives at cmd/generate-fix/ in the QuickFIX/Go repo:
# Build the generator
go install github.com/quickfixgo/quickfix/cmd/generate-fix
# Run against all spec files
generate-fix spec/FIX40.xml spec/FIX41.xml spec/FIX42.xml \
spec/FIX43.xml spec/FIX44.xml spec/FIX50.xml spec/FIX50SP1.xml \
spec/FIX50SP2.xml spec/FIXT11.xmlWhat gets generated: the complete mapping
The generator produces four categories of Go code from the XML:
1. Tag constants (tag package)
Every field's number attribute → a typed constant:
// Generated from: <field number="55" name="Symbol" type="STRING" />
package tag
import "github.com/quickfixgo/quickfix"
const Symbol quickfix.Tag = 55
const MDReqID quickfix.Tag = 262
const MarketDepth quickfix.Tag = 264
const SubscriptionRequestType quickfix.Tag = 263
const MDEntryType quickfix.Tag = 269
const MDEntryPx quickfix.Tag = 270
const MDEntrySize quickfix.Tag = 271
const MDUpdateAction quickfix.Tag = 2792. Field types (field package)
Each field becomes a Go struct wrapping the appropriate QuickFIX base type. The XML type attribute maps to Go types:
| XML Type | QuickFIX Base Type | Go Type |
|---|---|---|
| STRING | quickfix.FIXString | string |
| INT | quickfix.FIXInt | int |
| CHAR | quickfix.FIXString | string |
| FLOAT / PRICE | quickfix.FIXDecimal | decimal.Decimal |
| QTY / AMT | quickfix.FIXDecimal | decimal.Decimal |
| BOOLEAN | quickfix.FIXBoolean | bool |
| UTCTIMESTAMP | quickfix.FIXUTCTimestamp | time.Time |
Generated field struct example:
// Generated from: <field number="55" name="Symbol" type="STRING" />
package field
import "github.com/quickfixgo/quickfix"
import "github.com/quickfixgo/quickfix/tag"
type SymbolField struct{ quickfix.FIXString }
func (f SymbolField) Tag() quickfix.Tag { return tag.Symbol }
func NewSymbol(val string) SymbolField {
return SymbolField{quickfix.FIXString(val)}
}
// Generated from: <field number="270" name="MDEntryPx" type="PRICE" />
type MDEntryPxField struct{ quickfix.FIXDecimal }
func (f MDEntryPxField) Tag() quickfix.Tag { return tag.MDEntryPx }
func NewMDEntryPx(val decimal.Decimal, scale int32) MDEntryPxField {
return MDEntryPxField{quickfix.FIXDecimal{Decimal: val, Scale: scale}}
}3. Enum constants (enum package)
Fields with <value> children generate named string constants:
// Generated from SubscriptionRequestType field values
package enum
type SubscriptionRequestType string
const (
SubscriptionRequestType_SNAPSHOT SubscriptionRequestType = "0"
SubscriptionRequestType_SNAPSHOT_PLUS_UPDATES SubscriptionRequestType = "1"
SubscriptionRequestType_DISABLE_PREVIOUS_SNAPSHOT_PLUS_UPDATE_REQUEST SubscriptionRequestType = "2"
)
type MDEntryType string
const (
MDEntryType_BID MDEntryType = "0"
MDEntryType_OFFER MDEntryType = "1"
MDEntryType_TRADE MDEntryType = "2"
MDEntryType_OPENING_PRICE MDEntryType = "4"
MDEntryType_CLOSING_PRICE MDEntryType = "5"
MDEntryType_SETTLEMENT_PRICE MDEntryType = "6"
MDEntryType_TRADING_SESSION_HIGH_PRICE MDEntryType = "7"
MDEntryType_TRADING_SESSION_LOW_PRICE MDEntryType = "8"
MDEntryType_VWAP MDEntryType = "9"
MDEntryType_TRADE_VOLUME MDEntryType = "B"
)
type MDUpdateAction string
const (
MDUpdateAction_NEW MDUpdateAction = "0"
MDUpdateAction_CHANGE MDUpdateAction = "1"
MDUpdateAction_DELETE MDUpdateAction = "2"
)4. Message types (per-version packages)
This is the big one. Each message becomes its own subpackage under the version package (e.g., fix44/marketdatarequest/). The generated file for MarketDataRequest alone is over 3,000 lines because the Instrument component contains dozens of fields that all get flattened into the NoRelatedSym repeating group entry.
Here's the critical generated code (simplified for readability):
// fix44/marketdatarequest/MarketDataRequest.generated.go
package marketdatarequest
import (
"github.com/quickfixgo/quickfix"
"github.com/quickfixgo/quickfix/fix44"
"github.com/quickfixgo/quickfix/field"
"github.com/quickfixgo/quickfix/enum"
"github.com/quickfixgo/quickfix/tag"
)
// MarketDataRequest is the fix44 MarketDataRequest type, MsgType = V
type MarketDataRequest struct {
fix44.Header
*quickfix.Body
fix44.Trailer
Message *quickfix.Message
}
// New creates a MarketDataRequest - only required fields as parameters
func New(
mdreqid field.MDReqIDField,
subscriptionrequesttype field.SubscriptionRequestTypeField,
marketdepth field.MarketDepthField,
) (m MarketDataRequest) {
m.Message = quickfix.NewMessage()
m.Header = fix44.NewHeader(&m.Message.Header)
m.Header.Set(field.NewMsgType("V"))
m.Set(mdreqid)
m.Set(subscriptionrequesttype)
m.Set(marketdepth)
return
}
// Typed getters - return (value, MessageRejectError)
func (m MarketDataRequest) GetMDReqID() (v string, err quickfix.MessageRejectError) {
var f field.MDReqIDField
if err = m.Get(&f); err == nil { v = f.Value() }
return
}
func (m MarketDataRequest) GetSubscriptionRequestType() (v enum.SubscriptionRequestType, err quickfix.MessageRejectError) {
var f field.SubscriptionRequestTypeField
if err = m.Get(&f); err == nil { v = f.Value() }
return
}
func (m MarketDataRequest) GetMarketDepth() (v int, err quickfix.MessageRejectError) {
var f field.MarketDepthField
if err = m.Get(&f); err == nil { v = f.Value() }
return
}
// Typed setters
func (m MarketDataRequest) SetMDReqID(v string) { m.Set(field.NewMDReqID(v)) }
func (m MarketDataRequest) SetMarketDepth(v int) { m.Set(field.NewMarketDepth(v)) }
// Repeating group types
type NoMDEntryTypesRepeatingGroup struct{ *quickfix.RepeatingGroup }
type NoRelatedSymRepeatingGroup struct{ *quickfix.RepeatingGroup }
// Route function - THIS is what the MessageRouter uses
type RouteOut func(msg MarketDataRequest, sessionID quickfix.SessionID) quickfix.MessageRejectError
func Route(router RouteOut) (string, string, quickfix.MessageRoute) {
r := func(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
return router(FromMessage(msg), sessionID)
}
return "FIX.4.4", "V", r
}
func FromMessage(m *quickfix.Message) MarketDataRequest {
// Wraps raw message into typed struct
// ...
}
The generation flow visualized

Why this matters
Without code generation, working with FIX in Go (or any language) means dealing with raw tag numbers and string values:
// WITHOUT generated types - error-prone, no compile-time safety
msg.Header.SetField(tag.MsgType, quickfix.FIXString("V"))
msg.Body.SetField(quickfix.Tag(262), quickfix.FIXString("MDR-001")) // is 262 MDReqID? I think so...
msg.Body.SetField(quickfix.Tag(263), quickfix.FIXString("1")) // what's 263 again?
msg.Body.SetField(quickfix.Tag(264), quickfix.FIXInt(1)) // wait, is this INT or STRING?With generated types:
// WITH generated types - type-safe, self-documenting, compiler-checked
req := marketdatarequest.New(
field.NewMDReqID("MDR-001"),
field.NewSubscriptionRequestType(enum.SubscriptionRequestType_SNAPSHOT_PLUS_UPDATES),
field.NewMarketDepth(1),
)If you pass the wrong type, the compiler catches it. If you try to use a field that doesn't exist on a particular message, the compiler catches it. If you misspell a field name, the compiler catches it. This is a massive win for a protocol where a single wrong tag number means your message gets rejected by the exchange and your order doesn't execute.
Modifying the data dictionary
Exchanges frequently define custom fields and message extensions. To accommodate them, copy the standard spec file and edit it:
cp spec/FIX44.xml spec/FIX44_CUSTOM.xmlAdd your custom fields:
<fields>
<!-- Standard fields... -->
<!-- Custom exchange-specific fields (tag numbers 5000+) -->
<field number="5001" name="ExchangeOrderID" type="STRING" />
<field number="5002" name="ClientTier" type="INT">
<value enum="1" description="PLATINUM" />
<value enum="2" description="GOLD" />
<value enum="3" description="STANDARD" />
</field>
</fields>Add them to relevant messages:
<message name="ExecutionReport" msgtype="8" msgcat="app">
<!-- standard fields... -->
<field name="ExchangeOrderID" required="N" />
<field name="ClientTier" required="N" />
</message>Regenerate:
generate-fix spec/FIX44_CUSTOM.xmlNow you have type-safe Go code for your custom fields. Point your QuickFIX/Go config at the custom dictionary:
DataDictionary=spec/FIX44_CUSTOM.xmlBuilding the Market Data Server (Full Implementation)
Here's a complete, working market data acceptor. This is not a toy example - it handles MarketDataRequest subscriptions, maintains a subscription registry, and sends both full refreshes and incremental updates. I've stripped it to the essential logic but kept enough structure that you can extend it.
Project structure
fix-marketdata/ ├── cmd/ │ ├── acceptor/ │ │ └── main.go │ └── initiator/ │ └── main.go ├── config/ │ ├── acceptor.cfg │ └── initiator.cfg ├── spec/ │ └── FIX44.xml ├── internal/ │ └── marketdata/ │ └── server.go ├── store/ (created at runtime) ├── log/ (created at runtime) └── Makefile
The market data server - internal/marketdata/server.go
package marketdata
import (
"fmt"
"sync"
"time"
"github.com/quickfixgo/quickfix"
"github.com/quickfixgo/quickfix/enum"
"github.com/quickfixgo/quickfix/field"
"github.com/quickfixgo/quickfix/fix44/marketdatarequest"
"github.com/quickfixgo/quickfix/fix44/marketdatasnapshotfullrefresh"
"github.com/quickfixgo/quickfix/fix44/marketdataincrementalrefresh"
"github.com/shopspring/decimal"
)
// Subscription tracks what a client has asked for
type Subscription struct {
MDReqID string
SessionID quickfix.SessionID
Symbols []string
Depth int
}
// Server implements the QuickFIX/Go Application interface
type Server struct {
*quickfix.MessageRouter
mu sync.RWMutex
subscriptions map[string]*Subscription // keyed by MDReqID
}
func NewServer() *Server {
s := &Server{
MessageRouter: quickfix.NewMessageRouter(),
subscriptions: make(map[string]*Subscription),
}
// Register handler for MarketDataRequest (MsgType=V)
s.AddRoute(marketdatarequest.Route(s.onMarketDataRequest))
return s
}
// --- Application interface implementation ---
func (s *Server) OnCreate(sessionID quickfix.SessionID) {
fmt.Printf("[%s] Session created\n", time.Now().UTC().Format(time.RFC3339))
}
func (s *Server) OnLogon(sessionID quickfix.SessionID) {
fmt.Printf("[%s] Client logged on: %s\n",
time.Now().UTC().Format(time.RFC3339), sessionID)
}
func (s *Server) OnLogout(sessionID quickfix.SessionID) {
fmt.Printf("[%s] Client logged out: %s\n",
time.Now().UTC().Format(time.RFC3339), sessionID)
// Clean up subscriptions for this session
s.removeSessionSubscriptions(sessionID)
}
func (s *Server) ToAdmin(msg *quickfix.Message, sessionID quickfix.SessionID) {}
func (s *Server) ToApp(msg *quickfix.Message, sessionID quickfix.SessionID) error {
return nil
}
func (s *Server) FromAdmin(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
return nil
}
func (s *Server) FromApp(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
// Delegate to the MessageRouter, which dispatches to type-safe handlers
return s.Route(msg, sessionID)
}
// --- Business logic ---
func (s *Server) onMarketDataRequest(
msg marketdatarequest.MarketDataRequest,
sessionID quickfix.SessionID,
) quickfix.MessageRejectError {
mdReqID, err := msg.GetMDReqID()
if err != nil {
return err
}
subType, err := msg.GetSubscriptionRequestType()
if err != nil {
return err
}
switch subType {
case enum.SubscriptionRequestType_SNAPSHOT:
return s.handleSnapshot(msg, sessionID, mdReqID)
case enum.SubscriptionRequestType_SNAPSHOT_PLUS_UPDATES:
return s.handleSubscribe(msg, sessionID, mdReqID)
case enum.SubscriptionRequestType_DISABLE_PREVIOUS_SNAPSHOT_PLUS_UPDATE_REQUEST:
return s.handleUnsubscribe(mdReqID)
}
return nil
}
func (s *Server) handleSnapshot(
msg marketdatarequest.MarketDataRequest,
sessionID quickfix.SessionID,
mdReqID string,
) quickfix.MessageRejectError {
symbols := s.extractSymbols(msg)
for _, symbol := range symbols {
s.sendFullRefresh(sessionID, mdReqID, symbol)
}
return nil
}
func (s *Server) handleSubscribe(
msg marketdatarequest.MarketDataRequest,
sessionID quickfix.SessionID,
mdReqID string,
) quickfix.MessageRejectError {
symbols := s.extractSymbols(msg)
depth, _ := msg.GetMarketDepth()
sub := &Subscription{
MDReqID: mdReqID,
SessionID: sessionID,
Symbols: symbols,
Depth: depth,
}
s.mu.Lock()
s.subscriptions[mdReqID] = sub
s.mu.Unlock()
// Send initial snapshot for each symbol
for _, symbol := range symbols {
s.sendFullRefresh(sessionID, mdReqID, symbol)
}
fmt.Printf("[%s] New subscription: %s for %v (depth=%d)\n",
time.Now().UTC().Format(time.RFC3339), mdReqID, symbols, depth)
return nil
}
func (s *Server) handleUnsubscribe(mdReqID string) quickfix.MessageRejectError {
s.mu.Lock()
delete(s.subscriptions, mdReqID)
s.mu.Unlock()
fmt.Printf("[%s] Unsubscribed: %s\n",
time.Now().UTC().Format(time.RFC3339), mdReqID)
return nil
}
func (s *Server) sendFullRefresh(
sessionID quickfix.SessionID,
mdReqID string,
symbol string,
) {
snapshot := marketdatasnapshotfullrefresh.New()
snapshot.SetMDReqID(mdReqID)
snapshot.SetSymbol(symbol)
entries := marketdatasnapshotfullrefresh.NewNoMDEntriesRepeatingGroup()
// In production, these come from your market data source
// (RabbitMQ consumer, in-memory book, upstream FIX feed, etc.)
bid := entries.Add()
bid.SetMDEntryType(enum.MDEntryType_BID)
bid.SetMDEntryPx(decimal.NewFromFloat(153.25), 2)
bid.SetMDEntrySize(decimal.NewFromFloat(800), 0)
bid.SetMDEntryID("BID-" + symbol + "-001")
ask := entries.Add()
ask.SetMDEntryType(enum.MDEntryType_OFFER)
ask.SetMDEntryPx(decimal.NewFromFloat(153.28), 2)
ask.SetMDEntrySize(decimal.NewFromFloat(500), 0)
ask.SetMDEntryID("ASK-" + symbol + "-001")
snapshot.SetNoMDEntries(entries)
if sendErr := quickfix.SendToTarget(snapshot, sessionID); sendErr != nil {
fmt.Printf("[ERROR] Failed to send snapshot: %v\n", sendErr)
}
}
// PublishUpdate sends an incremental refresh to all subscribers for a symbol.
// Call this from your RabbitMQ consumer when market data changes.
func (s *Server) PublishUpdate(symbol string, entryType enum.MDEntryType,
action enum.MDUpdateAction, price float64, size float64, entryID string) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sub := range s.subscriptions {
if !containsSymbol(sub.Symbols, symbol) {
continue
}
update := marketdataincrementalrefresh.New()
update.SetMDReqID(sub.MDReqID)
entries := marketdataincrementalrefresh.NewNoMDEntriesRepeatingGroup()
entry := entries.Add()
entry.SetMDUpdateAction(action)
entry.SetMDEntryID(entryID)
entry.SetSymbol(symbol)
entry.SetMDEntryType(entryType)
entry.SetMDEntryPx(decimal.NewFromFloat(price), 2)
entry.SetMDEntrySize(decimal.NewFromFloat(size), 0)
update.SetNoMDEntries(entries)
if err := quickfix.SendToTarget(update, sub.SessionID); err != nil {
fmt.Printf("[ERROR] Failed to send update to %s: %v\n",
sub.SessionID, err)
}
}
}
func (s *Server) extractSymbols(msg marketdatarequest.MarketDataRequest) []string {
var symbols []string
noRelatedSym, err := msg.GetNoRelatedSym()
if err != nil {
return symbols
}
for i := 0; i < noRelatedSym.Len(); i++ {
group := noRelatedSym.Get(i)
symbol, err := group.GetSymbol()
if err == nil {
symbols = append(symbols, symbol)
}
}
return symbols
}
func (s *Server) removeSessionSubscriptions(sessionID quickfix.SessionID) {
s.mu.Lock()
defer s.mu.Unlock()
for id, sub := range s.subscriptions {
if sub.SessionID == sessionID {
delete(s.subscriptions, id)
}
}
}
func containsSymbol(symbols []string, target string) bool {
for _, s := range symbols {
if s == target {
return true
}
}
return false
}The main entry point - cmd/acceptor/main.go
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/quickfixgo/quickfix"
"fix-marketdata/internal/marketdata"
)
func main() {
cfgFile, err := os.Open("config/acceptor.cfg")
if err != nil {
fmt.Printf("Failed to open config: %v\n", err)
os.Exit(1)
}
defer cfgFile.Close()
settings, err := quickfix.ParseSettings(cfgFile)
if err != nil {
fmt.Printf("Failed to parse settings: %v\n", err)
os.Exit(1)
}
app := marketdata.NewServer()
// File store for persistent sequence numbers
storeFactory := quickfix.NewFileStoreFactory(settings)
// File logger for message audit trail
logFactory, err := quickfix.NewFileLogFactory(settings)
if err != nil {
fmt.Printf("Failed to create log factory: %v\n", err)
os.Exit(1)
}
acceptor, err := quickfix.NewAcceptor(app, storeFactory, settings, logFactory)
if err != nil {
fmt.Printf("Failed to create acceptor: %v\n", err)
os.Exit(1)
}
if err := acceptor.Start(); err != nil {
fmt.Printf("Failed to start acceptor: %v\n", err)
os.Exit(1)
}
defer acceptor.Stop()
fmt.Println("FIX Market Data Server started. Waiting for connections...")
// Graceful shutdown on SIGINT/SIGTERM
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
<-interrupt
fmt.Println("Shutting down...")
}Building
# Makefile
.PHONY: build run-acceptor run-initiator clean
build:
@mkdir -p store log
go build -o bin/acceptor ./cmd/acceptor
go build -o bin/initiator ./cmd/initiator
run-acceptor: build
./bin/acceptor
run-initiator: build
./bin/initiator
clean:
rm -rf bin/ store/ log/Building the Market Data Client (Initiator)
The client side connects to the market data server, sends subscription requests, and processes incoming snapshots and incremental updates.
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/quickfixgo/quickfix"
"github.com/quickfixgo/quickfix/enum"
"github.com/quickfixgo/quickfix/field"
"github.com/quickfixgo/quickfix/fix44/marketdatarequest"
"github.com/quickfixgo/quickfix/fix44/marketdatasnapshotfullrefresh"
"github.com/quickfixgo/quickfix/fix44/marketdataincrementalrefresh"
)
type MarketDataClient struct {
*quickfix.MessageRouter
sessionID quickfix.SessionID
}
func NewMarketDataClient() *MarketDataClient {
c := &MarketDataClient{
MessageRouter: quickfix.NewMessageRouter(),
}
c.AddRoute(marketdatasnapshotfullrefresh.Route(c.onFullRefresh))
c.AddRoute(marketdataincrementalrefresh.Route(c.onIncrementalRefresh))
return c
}
func (c *MarketDataClient) OnCreate(sessionID quickfix.SessionID) {
c.sessionID = sessionID
}
func (c *MarketDataClient) OnLogon(sessionID quickfix.SessionID) {
fmt.Printf("[%s] Logged on to %s\n",
time.Now().UTC().Format(time.RFC3339), sessionID)
// Subscribe to market data after successful logon
c.subscribe(sessionID)
}
func (c *MarketDataClient) OnLogout(sessionID quickfix.SessionID) {
fmt.Printf("[%s] Logged out from %s\n",
time.Now().UTC().Format(time.RFC3339), sessionID)
}
func (c *MarketDataClient) ToAdmin(msg *quickfix.Message, sessionID quickfix.SessionID) {
// If you need to add username/password to Logon:
// msgType, _ := msg.Header.GetString(tag.MsgType)
// if msgType == "A" {
// msg.Body.SetField(tag.Username, quickfix.FIXString("myuser"))
// msg.Body.SetField(tag.Password, quickfix.FIXString("mypass"))
// }
}
func (c *MarketDataClient) ToApp(msg *quickfix.Message, sessionID quickfix.SessionID) error {
return nil
}
func (c *MarketDataClient) FromAdmin(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
return nil
}
func (c *MarketDataClient) FromApp(msg *quickfix.Message, sessionID quickfix.SessionID) quickfix.MessageRejectError {
return c.Route(msg, sessionID)
}
func (c *MarketDataClient) subscribe(sessionID quickfix.SessionID) {
req := marketdatarequest.New(
field.NewMDReqID(fmt.Sprintf("MDR-%d", time.Now().UnixNano())),
field.NewSubscriptionRequestType(enum.SubscriptionRequestType_SNAPSHOT_PLUS_UPDATES),
field.NewMarketDepth(1), // top of book
)
// Request bid and offer data
entryTypes := marketdatarequest.NewNoMDEntryTypesRepeatingGroup()
bidType := entryTypes.Add()
bidType.SetMDEntryType(enum.MDEntryType_BID)
askType := entryTypes.Add()
askType.SetMDEntryType(enum.MDEntryType_OFFER)
req.SetNoMDEntryTypes(entryTypes)
// Subscribe to AAPL and MSFT
symbols := marketdatarequest.NewNoRelatedSymRepeatingGroup()
aapl := symbols.Add()
aapl.SetSymbol("AAPL")
msft := symbols.Add()
msft.SetSymbol("MSFT")
req.SetNoRelatedSym(symbols)
if err := quickfix.SendToTarget(req, sessionID); err != nil {
fmt.Printf("[ERROR] Failed to send subscription: %v\n", err)
} else {
fmt.Println("Market data subscription sent for AAPL, MSFT")
}
}
func (c *MarketDataClient) onFullRefresh(
msg marketdatasnapshotfullrefresh.MarketDataSnapshotFullRefresh,
sessionID quickfix.SessionID,
) quickfix.MessageRejectError {
symbol, _ := msg.GetSymbol()
entries, err := msg.GetNoMDEntries()
if err != nil {
return err
}
fmt.Printf("\n=== FULL REFRESH: %s ===\n", symbol)
for i := 0; i < entries.Len(); i++ {
entry := entries.Get(i)
entryType, _ := entry.GetMDEntryType()
price, _ := entry.GetMDEntryPx()
size, _ := entry.GetMDEntrySize()
typeName := "UNKNOWN"
switch entryType {
case enum.MDEntryType_BID:
typeName = "BID"
case enum.MDEntryType_OFFER:
typeName = "ASK"
case enum.MDEntryType_TRADE:
typeName = "TRADE"
}
fmt.Printf(" %s: %s @ %s\n", typeName, price.String(), size.String())
}
return nil
}
func (c *MarketDataClient) onIncrementalRefresh(
msg marketdataincrementalrefresh.MarketDataIncrementalRefresh,
sessionID quickfix.SessionID,
) quickfix.MessageRejectError {
entries, err := msg.GetNoMDEntries()
if err != nil {
return err
}
for i := 0; i < entries.Len(); i++ {
entry := entries.Get(i)
action, _ := entry.GetMDUpdateAction()
symbol, _ := entry.GetSymbol()
entryType, _ := entry.GetMDEntryType()
price, _ := entry.GetMDEntryPx()
size, _ := entry.GetMDEntrySize()
actionName := "???"
switch action {
case enum.MDUpdateAction_NEW:
actionName = "NEW"
case enum.MDUpdateAction_CHANGE:
actionName = "CHG"
case enum.MDUpdateAction_DELETE:
actionName = "DEL"
}
fmt.Printf("[UPDATE] %s %s %s %s @ %s\n",
actionName, symbol, entryType, price.String(), size.String())
}
return nil
}
func main() {
cfgFile, err := os.Open("config/initiator.cfg")
if err != nil {
fmt.Printf("Failed to open config: %v\n", err)
os.Exit(1)
}
defer cfgFile.Close()
settings, err := quickfix.ParseSettings(cfgFile)
if err != nil {
fmt.Printf("Failed to parse settings: %v\n", err)
os.Exit(1)
}
app := NewMarketDataClient()
storeFactory := quickfix.NewFileStoreFactory(settings)
logFactory, err := quickfix.NewFileLogFactory(settings)
if err != nil {
fmt.Printf("Failed to create log factory: %v\n", err)
os.Exit(1)
}
initiator, err := quickfix.NewInitiator(app, storeFactory, settings, logFactory)
if err != nil {
fmt.Printf("Failed to create initiator: %v\n", err)
os.Exit(1)
}
if err := initiator.Start(); err != nil {
fmt.Printf("Failed to start initiator: %v\n", err)
os.Exit(1)
}
defer initiator.Stop()
fmt.Println("FIX Market Data Client started. Connecting...")
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
<-interrupt
fmt.Println("Shutting down...")
}Production Deployment: systemd, Networking, and Monitoring
This is where theory meets reality. I've seen enough FIX engines deployed in production to know that the protocol implementation is maybe 30% of the work. The other 70% is operations: process management, network configuration, monitoring, and the regulatory audit trail.
Process management with systemd
In 2017, if you're deploying Go services on Linux, you're using systemd. Period. Docker is great for packaging and CI, and I use it heavily for build reproducibility, but for long-running financial services that need reliable process management, automatic restart, and clean integration with the OS... systemd is the answer.
Here's the unit file I use:
# /etc/systemd/system/fix-marketdata.service
[Unit]
Description=FIX Market Data Distribution Engine
Documentation=https://github.com/your-org/fix-marketdata
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=fixengine
Group=fixengine
WorkingDirectory=/opt/fixengine
Environment="FIX_CONFIG=/etc/fixengine/acceptor.cfg"
Environment="GOMAXPROCS=4"
ExecStart=/opt/fixengine/bin/fix-marketdata
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10
StartLimitInterval=300
StartLimitBurst=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/fixengine/store /opt/fixengine/log /var/log/fixengine
# Resource limits
LimitNOFILE=65536
LimitNPROC=4096
StandardOutput=journal
StandardError=journal
SyslogIdentifier=fix-marketdata
[Install]
WantedBy=multi-user.targetKey points:
Type=simple - Go doesn't do the double-fork dance that traditional Unix daemons use. Your binary runs in the foreground, systemd manages it directly. If you want to use Type=notify (where your application explicitly signals readiness), use the coreos/go-systemd package to call daemon.SdNotify() after acceptor.Start() succeeds.
Restart=on-failure with rate limiting - StartLimitInterval=300 and StartLimitBurst=5 means if the service crashes 5 times within 5 minutes, systemd gives up. This prevents crash loops from hammering the exchange with rapid Logon/Logout cycles, which will get your CompID banned faster than you can say "operational risk."
LimitNOFILE=65536 - each FIX session is a TCP socket. The default 1024 file descriptor limit will kill you fast when you have 200+ concurrent sessions. Set this high.
Security hardening - NoNewPrivileges, ProtectSystem=strict, ProtectHome. FIX engines don't need root access, don't need to write to system directories, and shouldn't be able to read home directories. Principle of least privilege.
Deploy and enable:
# Create service user
sudo useradd -r -s /sbin/nologin fixengine
# Deploy binary
sudo mkdir -p /opt/fixengine/{bin,store,log,spec}
sudo cp bin/fix-marketdata /opt/fixengine/bin/
sudo cp spec/FIX44.xml /opt/fixengine/spec/
sudo cp config/acceptor.cfg /etc/fixengine/acceptor.cfg
# Set permissions
sudo chown -R fixengine:fixengine /opt/fixengine
sudo chmod 750 /opt/fixengine/bin/fix-marketdata
# Install and start
sudo cp fix-marketdata.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable fix-marketdata
sudo systemctl start fix-marketdata
# Verify
sudo systemctl status fix-marketdata
sudo journalctl -u fix-marketdata -fNetwork architecture
FIX runs over TCP with no standardized port - exchanges assign specific port numbers per session. Your firewall configuration needs to be precise.

IP whitelisting is STANDARD PRACTICE. Exchanges will reject connections from unknown IPs. Clients should only be able to connect from known IPs.
For connectivity to exchanges, three tiers:
Colocation - your servers physically in the exchange data center (Equinix NY4/NY5 for US equities, LD4 for London, TY3 for Tokyo). Cross-connects provide sub-millisecond round trips. This is what HFT firms use.
Leased lines - MPLS circuits from your data center to the exchange. Deterministic latency, typically single-digit milliseconds for domestic routes.
VPN tunnels - IPsec over the internet. Adequate for non-latency-sensitive connections. I use this for broker connectivity where sub-millisecond doesn't matter.
TLS for FIX (FIXS)
The FIX Trading Community published the FIXS (FIX-over-TLS) standard specifying TLS 1.2 with mutual certificate authentication. In practice, many firms rely on network-level security (dedicated lines, VPN) instead, since TLS adds measurable latency to every message. But if you're transmitting over the public internet, FIXS is the right approach.
QuickFIX/Go supports TLS via the config:
[SESSION]
SocketUseSSL=Y
SocketCertificateFile=/etc/fixengine/tls/cert.pem
SocketKeyFile=/etc/fixengine/tls/key.pemMessage store options
FIX's sequence number mechanism requires persistent state. If your engine restarts and forgets its sequence numbers, the counterparty will reject your Logon because the sequences don't match, and you're dead in the water until someone manually intervenes.
QuickFIX/Go provides:
| Store Type | Persistence | Performance | Use Case |
|---|---|---|---|
| MemoryStoreFactory | None | Fastest | Dev/test, or ResetOnLogon=Y sessions |
| FileStoreFactory | Disk (flat) | Good (SSD) | Production default |
| Custom (DB-backed) | Database | Variable | HA with shared state |
For production, I use FileStoreFactory on SSD-backed storage. The files are small (just sequence numbers and a message index), reads are rare (only during gap-fill recovery), and writes are sequential. An SSD handles this without breaking a sweat.
For high-availability deployments where you need failover between hosts, you need shared state - either an NFS mount (simple but introduces a SPOF) or a database-backed custom store (PostgreSQL works well, and I'm already running it for everything else in my stack).
Monitoring: Prometheus + Grafana
I've run Nagios. I've run Zabbix. They work. But now, if you're starting fresh, Prometheus + Grafana is the stack. Pull-based collection, dimensional labels, PromQL for alerting, and Go has excellent client library support via prometheus/client_golang.
Key metrics to expose:
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
)
var (
messagesReceived = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "fix_messages_received_total",
Help: "Total FIX messages received",
},
[]string{"session", "msg_type"},
)
messagesSent = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "fix_messages_sent_total",
Help: "Total FIX messages sent",
},
[]string{"session", "msg_type"},
)
messageLatency = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "fix_message_processing_seconds",
Help: "Time to process incoming FIX messages",
Buckets: []float64{.0001, .0005, .001, .005, .01, .05, .1},
},
[]string{"session", "msg_type"},
)
activeSessions = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "fix_active_sessions",
Help: "Number of currently connected FIX sessions",
},
)
activeSubscriptions = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "fix_active_subscriptions",
Help: "Number of active market data subscriptions",
},
)
)
func init() {
prometheus.MustRegister(messagesReceived, messagesSent,
messageLatency, activeSessions, activeSubscriptions)
}
// Start metrics server on a separate port (NOT the FIX port)
func startMetricsServer() {
http.Handle("/metrics", promhttp.Handler())
go http.ListenAndServe(":9090", nil)
}
Then in your Grafana dashboard, you get real-time visibility into message throughput, latency percentiles, session health, and subscription counts. Set alerts for session disconnects, latency spikes, and abnormal message rates.
For log aggregation, ship FIX message logs to the ELK stack (Elasticsearch + Logstash + Kibana). Every FIX message log is a regulatory audit record - MiFID II requires you to keep them, and you need to be able to search them when the regulator asks "show me every order for Client X on October 15th."
# Logstash config for FIX message logs (simplified)
input {
file {
path => "/opt/fixengine/log/*.log"
type => "fix"
}
}
filter {
grok {
match => { "message" => "%{TIMESTAMP_ISO8601:timestamp}.*35=%{DATA:msg_type}\x01.*49=%{DATA:sender}\x01.*56=%{DATA:target}\x01" }
}
}
output {
elasticsearch {
hosts => ["localhost:9200"]
index => "fix-messages-%{+YYYY.MM.dd}"
}
}MiFID II: The Regulatory Freight Train
If you're building FIX infrastructure for European markets in late 2017, MiFID II is the 800-pound gorilla in the room. Enforcement starts January 3, 2018 - about ten weeks from when I'm writing this - and it's driving the single largest wave of FIX infrastructure upgrades in the protocol's history.
The FIX Trading Community published extension packs in February 2017 covering the key requirements:
Timestamp granularity - regulated markets must provide microsecond-precision timestamps. This means exchanges like ICE are adding microsecond precision to FIX SendingTime and TransactTime fields. If your parser chokes on 52=20171015-09:30:01.123456, you have a problem.
Algorithm and trader identification - every order must carry an AlgoID identifying the algorithm that generated it, or a trader ID if manually entered. New FIX tags are being added for this.
LEI (Legal Entity Identifier) - orders must identify the legal entity placing them via the 20-character LEI code.
Short-sell marking - orders must be flagged as short sells where applicable.
Transaction reporting - expanded post-trade reporting requirements with new fields for venue of execution, waiver indicators, and client identification.
The good news: as Hanno Klein of Deutsche Börse observed, most of these requirements map to existing FIX semantics. The protocol didn't need new message types - just new fields and extension packs on existing messages. The bad news: every firm in Europe needs to upgrade their FIX engines, test against exchange certification environments, and go live before January 3rd. The compliance teams are panicking, the engineering teams are firefighting, and the exchange connectivity teams are drowning in certification requests.
If you're building new FIX infrastructure right now, target FIX 4.4 with MiFID II extension pack fields in your data dictionary. This gives you the widest compatibility with existing counterparties while supporting the new regulatory fields.
Closing Thoughts
FIX protocol is one of those technologies that rewards deep understanding. On the surface it looks simple - just key-value pairs over TCP. But the session management semantics, the market data subscription model, the repeating group parsing rules, the sequence number recovery mechanisms - there's genuine complexity hiding under the ASCII surface, and getting it wrong means missed trades, corrupted order books, or regulatory violations.
Go is the right language for this in 2017. Not because it's trendy (though the Go community would disagree with "trendy" - they'd say "pragmatic"), but because the concurrency model maps perfectly to FIX engine requirements: each session is a goroutine, channel-based communication between the engine and your business logic, and a statically compiled binary that deploys as a single file with zero runtime dependencies. No JVM tuning. No .NET framework versioning hell. Copy the binary, write the systemd unit file, start the service.
QuickFIX/Go's code generation pipeline is the hidden gem. The jump from raw tag=value manipulation to typed Go structs with compile-time safety is enormous - it eliminates an entire category of runtime bugs that have plagued FIX implementations for decades. The generated code is verbose (3,000+ lines for a single message type), but verbosity is a worthwhile trade for correctness when you're handling financial messages.
For production deployment: keep it simple. A Linux VM, systemd, file-based message store on SSD, Prometheus for metrics, ELK for audit logs, and proper firewall rules. The fancy orchestration can come later when you've proven the engine works and you need horizontal scaling. For most FIX workloads, a single well-tuned server handles hundreds of sessions without breaking a sweat.
The immediate priority for anyone in European markets: get your MiFID II data dictionary extensions in place, test against exchange certification environments, and ship before January 3rd. The clock is ticking.
If you have questions, want to see more detailed examples, or need help with a specific exchange's FIX implementation, drop me a line. I'm always happy to talk protocol internals with people who appreciate the beauty of a well-sequenced session.
If you're facing similar challenges, let's talk.
Bring the current architecture context and delivery constraints, and we can map out a focused next step.
Book a Discovery CallNewsletter
Stay connected
Not ready for a call? Get the next post directly.