C++20 matching engine that accepts orders over a FIX 4.4 session, applies pro-rata allocation at each price level, and supports the standard order lifecycle (New, Cancel, Replace) with full FIX session semantics.
The repo is a portfolio project. It is small enough to read end-to-end in one sitting and large enough to make the algorithm and protocol choices visible.
Two things that production matching engines care about and most tutorials skip:
- FIX 4.4 session semantics. Pipe-delimited tag-value wire format,
3-digit checksum, sequence numbers, gap detection, heartbeats,
bilateral logout. The session is a pure state machine
(
fix::Session::on_bytesis total over its input). The TCP transport is a thin layer above it. - Pro-rata allocation at each price level. Orders at the same price share fills proportionally to their size, with FIFO tie-breaking for the rounding residual. FIFO matching is the simpler alternative and is included as a runtime-switchable mode for direct comparison.
| Axis | orderbook-sim | orderbook-fix |
|---|---|---|
| Wire protocol | Custom ASCII (B 100 1000) |
FIX 4.4 over TCP, full session |
| Matching algorithm | FIFO (price-time priority) | Pro-rata default, FIFO switchable |
| Concurrency | SPSC ring, single-thread matcher | One thread per FIX session, shared matcher under coarse lock |
| What it stresses | Lock-free queueing, branch prediction | Wire parsing, session state, allocation math |
orderbook-fix is the protocol + algorithm layer. orderbook-sim is the
queueing and matching primitives in isolation. The two repos share no
code; see docs/differs-from-orderbook-sim.md for why.
End-to-end FIX session bench, measured on:
- Apple M2 Pro, macOS 26.0 (Darwin 25.0), Apple Clang 17.0
- Release build (
-O3), loopback TCP, single accepting thread
The workload is 100k (50k for pro-rata) NewOrderSingle messages
alternating buy/sell around a 10-tick spread. Latency is wall-clock from
"client wrote FIX bytes" to "client received first ExecutionReport
bytes" through the kernel loopback. Warmup of 2000 messages is excluded.
Pro-rata, 50k messages on a continuously deepening book:
{
"n_messages": 50000,
"algo": "prorata",
"wall_seconds": 18.929877,
"msgs_per_sec": 2641.33,
"avg_ns": 350488,
"p50_ns": 524288,
"p95_ns": 1048576,
"p99_ns": 4194304,
"p999_ns": 4194304
}FIFO, 50k messages, same workload:
{
"n_messages": 50000,
"algo": "fifo",
"wall_seconds": 7.354462,
"msgs_per_sec": 6798.59,
"avg_ns": 121684,
"p50_ns": 65536,
"p95_ns": 524288,
"p99_ns": 1048576,
"p999_ns": 1048576
}Pro-rata pays ~2.5x in throughput because it snapshots every resting
order at the touched level (O(level depth) per match), while FIFO stops
as soon as the aggressor is filled. The latency histogram is logarithmic
(factor-2 buckets); the P99 numbers reflect the deepening book over the
run, not jitter. Raw outputs live in bench/results/.
FIFO at 200k messages on the same hardware:
{
"n_messages": 200000,
"algo": "fifo",
"wall_seconds": 33.597906,
"msgs_per_sec": 5952.75,
"p50_ns": 131072,
"p95_ns": 262144,
"p99_ns": 1048576,
"p999_ns": 8388608
}The harness is request/response: send one NewOrderSingle, block until
the first ExecutionReport is parsed, repeat. With one session and one
matcher thread (book_mu_ is a coarse mutex per the README architecture
section), wall throughput is bounded by the round-trip per message:
session decode + matcher lock + serialization + socket write + client
parse. On the dev box that ceiling is around 6k msgs/sec for FIFO and
~1.3k for pro-rata. A 1M FIFO run takes ~170 s wall and a 1M pro-rata
run takes ~13 min; neither is useful as a CI smoke. A client-pipelined
mode was prototyped (sender + drain thread on the same fd) but the
single-threaded Session::on_bytes plus book_mu_ on the server side
remain the bottleneck, so the wall time does not improve. The honest
takeaway: scale beyond 200k requires sharding sessions across matcher
threads, which is out of scope for this repo. The 200k FIFO number is
the largest end-to-end bench that fits in a CI smoke step.
The cache-line padding study (bench/cacheline_study.cpp) measures the
hot match path with and without alignas(64) on Order and
PriceLevel. Result on M2 Pro: alignment is neutral-to-slightly-worse,
because the matcher serializes through one mutex and the working set
fits in L1 already. Numbers in bench/results/cacheline_study.json.
A regression gate (make bench-regress) runs a 10k FIFO smoke and
fails CI if msgs_per_sec drops more than 30% below the committed
baseline at bench/results/bench_smoke_baseline.json.
To reproduce: make bench (CMake + Release build, no extra deps).
+--------------------------------------------------+
| net::TcpServer (one thread per session) |
| | |
| | recv() bytes |
| v |
| fix::Session (state machine, seq tracking) |
| | |
| | AppMessage (NewOrderSingle / Cancel / G) |
| v |
| core::Matcher (pro-rata or FIFO dispatch) |
| | |
| | OrderBook + PriceLevel mutation |
| v |
| core::OrderBook (per-symbol, intrusive list) |
+--------------------------------------------------+
| ExecutionReport bytes <- fix::Session <- events |
+--------------------------------------------------+
Full architecture, sequence-number recovery semantics, and concurrency
notes are in ARCHITECTURE.md.
Aggressor: buy 1000 at $100. Resting at $100, in time order: A=300, B=300, C=400.
T = 1000, to_fill = 1000
A: floor(1000 * 300 / 1000) = 300
B: floor(1000 * 300 / 1000) = 300
C: floor(1000 * 400 / 1000) = 400
residual = 0
Fills: A=300, B=300, C=400.
When rounding leaves a residual (aggressor 500 vs A=100, B=200, C=300):
T = 600, to_fill = 500
A: floor(500 * 100 / 600) = 83
B: floor(500 * 200 / 600) = 166
C: floor(500 * 300 / 600) = 250
sum = 499, residual = 1 -> goes FIFO to A (oldest)
Fills: A=84, B=166, C=250.
The pinned unit test for this case is pro_rata_test.cpp::ProportionalAllocationWithRounding.
make build # Release
make test # ctest
make bench # writes bench/results/bench_local.json
make asan # ASan + UBSan
make tsan # ThreadSanitizer
make fmt-check # clang-format
CMake target: 3.20+. C++20. The only external dependency is GoogleTest,
fetched at configure time via FetchContent. libFuzzer is opt-in via
-DOBFIX_FUZZ=ON and requires clang.
make build
./build/obfix_server --port 9876 --algo prorata &
# now connect with a FIX client to 127.0.0.1:9876
Or via docker-compose:
docker compose up --build
- Not FIX 5.0. We speak 4.4 only.
- Not FIXT.1.1. The transport is plain TCP; there is no separate transport session.
- Not a market data session. No QuoteRequest (35=R), Quote (35=S), or
market data subscription (35=V/W/X). Order entry only. For a market
data side, see (future)
SAY-5/mdfeed-itch. - Not a DropCopy publisher.
- No pre-trade risk checks. No STP, no cross-prevention.
- No persistent message store. Cold-start only; ResendRequest from the peer is answered with Logout. Production deployments pair this with a recovery tap.
- No SSL/TLS on FIX. Production wires
stunnelor terminates TLS at a sidecar.
7 test binaries, 80 individual test cases. Coverage in numbers:
fix_parser_test: 35 cases (valid messages, malformed, checksum bad, truncated, duplicate tag, large body, round-trip fidelity).fix_session_test: 15 cases (state transitions, gap detection, TestRequest, peer Logout, malformed framing).pro_rata_test: 8 cases (the math correctness lives here).matcher_test: 7 cases (FIFO vs pro-rata equivalence on degenerate inputs, hot-swap, walk across levels).order_book_test,price_level_test: 14 combined.session_e2e_test: 1 case driving a real TCP loopback session.
CI runs the suite under GCC and Clang, then again under ASan+UBSan,
TSan, and the libFuzzer smoke. See .github/workflows/ci.yml.
MIT. See LICENSE.