A working FIX 4.4 session boilerplate in Go, built around a fake exchange you can command.
It exists because the hard part of connecting to a venue is not parsing messages — libraries do that. The hard part is everything the session layer does when things go wrong: sequence gaps, resends, replayed messages, a session that is logged on and silent, orders left open when a connection drops. You normally learn those during a certification window, against a real venue, on a schedule that is not yours.
Here you learn them with curl.
Modeled on B3 EntryPoint, using quickfixgo/quickfix. Everything is generic FIX 4.4 except a handful of clearly marked venue-specific tags.
make up # exchange + both clients
docker compose logs -f # watch the wire
curl -s localhost:8081/admin/sessions | jq
curl -s localhost:8081/admin/orders | jq
curl -XPOST 'localhost:8081/admin/fill?clordid=CL000001&px=25.50'
make downOr without Docker, in three terminals:
export FIXLAB_OECLIENT_PASSWORD=oe-secret FIXLAB_DCCLIENT_PASSWORD=dc-secret
go run ./cmd/exchange
go run ./cmd/oe-client
go run ./cmd/dc-client ┌──────────────────────────────┐
oe-client ──FIX──┤ exchange │
(initiator) │ order-entry acceptor │
│ drop-copy acceptor ├── :8081 admin HTTP
dc-client ──FIX──┤ order book (no matching) │
(initiator) └──────────────────────────────┘
:9876 FIX
Three binaries. The venue is one process because a drop copy is generated from
the same order state as the order-entry report and must carry the same ExecID.
The clients are separate binaries because an order-entry client and a drop-copy
consumer are different jobs — and because quickfixgo cannot disconnect one
session out of several, so one session per process is what makes a client
recoverable.
Each drill is a scenario you run, a wire trace to read, and one thing to understand.
| # | Drill | Teaches |
|---|---|---|
| 01 | First logon | RawData auth, Text on Logon, why the password must never hit a log |
| 02 | Heartbeat & TestRequest | what "alive" proves, and what it does not |
| 03 | Rejected logon | how a venue refuses you, and why that is hard to diagnose |
| 04 | Order round trip | ExecType vs OrdStatus, cumulative vs incremental fields |
| 05 | Drop-copy fanout & dedup | why ExecID is the field everything hangs on |
| 06 | Sequence persistence | ResetOnLogon Y vs N, and what a store is actually for |
| 07 | Gap & ResendRequest | recovering a gap, and surviving PossDupFlag=Y |
| 08 | SequenceReset / GapFill | why admin messages are not replayed |
| 09 | Silent session & watchdog | the failure no FIX engine will detect for you |
| 10 | Cancel on disconnect | 35002/35003, and why the timeout window exists |
| 11 | Session-level Reject | 35=3 vs 35=j, and when you must not send either |
The control plane is how you make the venue misbehave on purpose. It is the analog of the exchange operator pressing buttons during a certification window.
GET /admin/sessions session state and seqnums
GET /admin/orders the venue's order book
POST /admin/fill?clordid=&px= fill an order completely
POST /admin/partial?clordid=&qty=&px= fill part of one
POST /admin/cancel?clordid=&reason= cancel at the venue's initiative
POST /admin/reject?clordid=&reason= reject a working order
POST /admin/cancel-all?reason= mass cancel open day orders
POST /admin/gap?n=&kind= open a sequence gap
POST /admin/silence?on= stay logged on, say nothing
POST /admin/reject-logon?reason=&on= refuse the next logon
Every drill test drives these same methods, so what the docs describe is what CI asserts.
go test ./... # every drill
go test ./... -update # rewrite the golden wire traces
FIXLAB_DUMP_WIRE=1 go test ./... -v -run TestDrill07 # print a full trace
# Real coverage. -coverpkg is not optional here: the drills live in their own
# package and drive everything else over a socket, so per-package measurement
# reports close to zero and tells you nothing.
go test -coverpkg=./... -coverprofile=coverage.out ./... && go tool cover -func=coverage.out | tail -1Each drill has an integration test that stands up a real acceptor, real
initiator sessions, and real FIX over loopback. Drill 07 additionally asserts
its wire trace byte for byte against a committed golden file, with only the
volatile fields removed — 9, 10, 52, 60, 122. Sequence numbers and
PossDupFlag are asserted, since a recovery test that ignored them would be
asserting nothing.
That is the guarantee: the trace printed in docs/drills/07 is the trace CI
checks. If the code stops emitting it, the build goes red rather than the
documentation quietly going stale.
Things worth knowing before you build on it, all verified against v0.9.10. Longer, symptom-first versions of each — with the source quoted and the tests that prove them — are in docs/field-notes.md.
-
There is no per-session disconnect. The
sessiontype is unexported.registry.gogives youSend,SendToTarget,ResetSession,UnregisterSession,SetNextSenderMsgSeqNum,SetNextTargetMsgSeqNum,GetExpectedSenderNum,GetExpectedTargetNum,GetMessageStore,GetLog— and nothing that closes one session.Initiator.Stop()unregisters every session it owns, soStart()afterwards leaves them unroutable; a real restart means building a freshInitiator. If you are coming from QuickFIX/J looking forSession.disconnect(reason, true), it is not there, and the trap is different in Go: you have no handle at all. -
The message stores are not synchronized.
memoryStoremutates plain ints with no lock. CallingGetExpectedSenderNumfrom an HTTP handler while the session goroutine is running is a data race that-racewill catch. This lab readsMsgSeqNum (34)off the messages the application is already handed instead — seeSeqNumsininternal/exchange. -
ToAdmincannot fail. It returns nothing, so a missing credential cannot abort an outbound Logon. Log it and let the venue's rejection tell you — and do not let one missing field suppress the rest of the Logon, or you will silently fail to arm cancel-on-disconnect too. -
Settings.SessionSettings()returns clones, not the live settings. It rebuilds a copy fromglobalSettingson every call, so mutating what it hands back changes nothing —NewInitiatorcalls it again and gets clean copies. Write toGlobalSettings(), which is live. This cost a silent no-op that only showed up under docker-compose; seeOverrideConnectHost. -
Never reject a reject. If your
FromAppanswers unknown message types with aBusinessMessageReject, and the counterparty does the same, then the first35=jeither side sends loops forever — both reject the rejection, at wire speed. Thirteen thousand messages in six seconds, the first time it happened here. Handle35=jand35=3explicitly and return nil. -
Rejects split in a place you would not guess. An out-of-range tag value on an application message produces a session Reject (
35=3); a missing conditionally-required field on the same message produces a Business Message Reject (35=j). Same validation pass, different layer — decided by the reject reason. Drill 11. -
Returning
quickfix.RejectLogon{Text: …}fromFromAdminis how an acceptor refuses a Logon. quickfixgo replies with a Logout carrying the reason, then drops the connection.
cmd/exchange fake venue: both acceptor sessions + admin API
cmd/oe-client order-entry initiator
cmd/dc-client drop-copy initiator
internal/exchange venue behavior: order state, ER fanout, commanded actions
internal/client the two initiator applications
internal/session shared: logon, credentials, watchdog, supervisor
internal/fixlog redacting log factory
internal/drills the tests behind docs/drills
spec/ the data dictionary, and what was changed in it
config/ quickfix settings, one per binary
Everything is under internal/. This is a reference, not a library — copy what
you need. No API stability is promised, and the code will change as quickfixgo
does.
Passwords come from the environment, keyed by SenderCompID:
FIXLAB_OECLIENT_PASSWORD
FIXLAB_DCCLIENT_PASSWORD
Never from a config file. The committed settings files contain no secrets, and
.env is gitignored. If the venue has no password configured for a
counterparty it accepts any password, so a first run works before you have set
anything up.
MIT — see LICENSE.
This product includes software developed by quickfixengine.org (http://www.quickfixengine.org/).
Modeled on B3 EntryPoint, and not affiliated with or endorsed by B3. B3's specifications are referenced, not reproduced — obtain the authoritative documents from B3. See NOTICE.md and spec/README.md.