This project implements a full vertical slice of a matching venue for one instrument: an order book core with three interchangeable price-level data structures (benchmarked against each other), pluggable matching policies (FIFO and pro-rata), a segmented write-ahead log with crash recovery, an account/escrow ledger, a single-threaded engine actor, and an axum HTTP + WebSocket gateway with HMAC-signed auth tokens and a snapshot-plus-delta market-data protocol.
The goal was not to build a production exchange, but to learn why real matching engines are built the way they are — and to measure the difference the data structures make.
┌───────────────────────────────────────────────┐
HTTP / WS clients │ axum gateway │
────────────────────► │ POST /users POST /order POST /order_faf│
│ GET /ws/market GET /ws/user (HMAC auth) │
└───────┬───────────────────▲───────────▲───────┘
│ bounded mpsc │ broadcast │ per-user
│ (Command) │ (frames) │ channels
┌───────▼───────────────────┴───────────┴───────┐
│ engine actor (dedicated OS thread) │
│ │
│ authorize ─► match ─► settle ─► publish │
│ ┌──────────┐ ┌─────────────────────────┐ │
│ │ Accounts │ │ OrderBook<S, M> │ │
│ │ balances │ │ S: Storage (BTreeMaps) │ │
│ │ escrow │ │ └─ PriceLevel impls │ │
│ │ owners │ │ M: MatchingEngine │ │
│ └────┬─────┘ └───────────┬─────────────┘ │
└───────┼────────────────────┼──────────────────┘
▼ ▼
accounts_wallog/ book_wallog/
(bincode WAL) (bincode WAL)
- Three price-level implementations behind one trait, from a naive
VecDequebaseline to a slab-arena intrusive linked list with O(1) cancel/modify — with Criterion benchmarks that show exactly what that buys you. - Zero-lock hot path. All matching state is owned by one OS thread; the async world talks to it through a bounded channel. No
Mutexanywhere near the book. - Serialize-once fan-out. Market-data frames are serialized to JSON a single time on the engine thread, wrapped in
Arc<Bytes>, and broadcast to every WebSocket subscriber by reference-count bump. - Write-ahead logging + deterministic replay. Both the book and the account ledger append length-prefixed bincode records to segmented logs and rebuild their full state on restart.
- Gap-free market data. Snapshot-then-delta protocol keyed by a monotonic sequence number, with the classic subscribe-before-snapshot ordering to avoid missing events.
- Escrow-based settlement. Funds/shares are moved to escrow when an order rests, released on cancel, and settled maker↔taker on every fill.
- 66 tests, including property-based tests (proptest) and
Rc-leak tests that prove the hand-rolled linked list frees its nodes.
- The order book core
- Price levels: three data structures, one trait
- Book storage
- Matching engines: FIFO and pro-rata
- Durability: the write-ahead log
- Accounts, escrow and settlement
- Concurrency model: the engine actor
- The network layer
- HTTP / WebSocket API
- Benchmarks
- Testing
- Running it
- Project layout
- Known limitations & future work
OrderBook<S, M> (src/order_book.rs) is generic over its storage backend and matching policy:
pub struct OrderBook<S, M> {
storage: S, // S: Storage — where price levels live
engine: M, // M: MatchingEngine — how fills are allocated
log: Option<WALWriter>,
base_info: BaseInfo, // cached best bid / best ask / spread
next_order_id: u64,
seq: u64, // monotonic sequence number, bumped per command
}Everything is composed through traits and resolved with static dispatch — swapping the level implementation or the matching policy is a one-line change at construction time and costs nothing at runtime:
let book = OrderBookBuilder::new()
.storage(DefaultStorage::<SlabPriceLevel>::new())
.engine(FifoEngine)
.with_log("./book_wallog") // append every accepted command
.with_replay("./book_wallog") // rebuild state from the log on startup
.build()?;Four command types flow through process_order (src/core/order.rs):
| Command | Behaviour |
|---|---|
Limit { price, size, side } |
Rests on the book. Crossing the opposite best price is rejected (OrderPriceOverlap) — in this design, taking liquidity is done explicitly with market orders. |
Market { size, side } |
Sweeps the book level by level from the best price. side names the book side being consumed (the maker side): Market { side: Ask } is a buy that eats the ask side. Partial fills happen when liquidity runs out. |
Cancel { order_id } |
Removes a resting order; empties and prunes the level if it was the last one. |
Modify { order_id, new_size } |
Resizes in place. Whether the order keeps or loses time priority is delegated to the matching engine: size down keeps queue position, size up sends it to the back (the standard exchange rule). |
Every accepted command produces an Ack:
pub struct Ack {
pub seq: u64, // sequence of this command
pub order_id: Option<OrderId>,
pub order_updates: Arc<Vec<OrderUpdate>>, // OrderAccepted / Fill / Cancelled / Modified
pub depth_updates: Arc<Vec<DepthUpdate>>, // (price, side, new_total_qty) per touched level
}The update vectors are Arc-wrapped on purpose: the same allocation is shared by the reply to the caller, the market-data broadcast, and the per-user event fan-out — nothing is cloned on the hot path.
This is the heart of the project. A price level is a FIFO queue of resting orders at one price that must also support cancel and modify by order id — and in real markets, cancels/modifies vastly outnumber trades. The PriceLevel trait (src/price_level/mod.rs) captures the interface (add, cancel, modify-with-optional-reprioritize, pop/push front, peek, iterate — iteration uses a generic associated type type Iter<'a>), and three implementations explore the design space:
src/price_level/slow_price_level/mod.rs
VecDeque<OrderData> + HashSet<OrderId> for membership. Cancels and modifies do a linear scan and an O(n) VecDeque::remove. Simple, correct, and exactly what you'd write first — it exists to be measured against.
src/price_level/fast_price_level/
The textbook exchange design: a doubly-linked list holding the FIFO queue, plus a hash map from OrderId straight to the list node, giving O(1) cancel, modify, and move-to-back. The list is hand-written in safe Rust (Rc<RefCell<Node>> forward links, Weak back links to break reference cycles — linked_list.rs), and the test suite includes explicit leak checks that downgrade nodes to Weak and assert the strong count reaches zero after every removal path.
The trade-off it demonstrates: asymptotics won, but every node is a separate heap allocation, every hop chases a pointer, and every access pays RefCell bookkeeping.
src/price_level/slab_price_level/
Same doubly-linked-list idea, but nodes live in a slab arena and links are plain usize indices instead of pointers (linked_list.rs). Freed slots are recycled, so a busy level reaches a steady state with no allocator traffic at all; nodes sit in contiguous memory, and there's no Rc/RefCell overhead. The lookup map is FxHashMap<OrderId, NodeIndex> (rustc-hash — a much cheaper hash than SipHash for trusted keys, used throughout the project). This is the implementation the server binary runs.
| add | cancel by id | modify by id | pop front | memory profile | |
|---|---|---|---|---|---|
SlowPriceLevel |
O(1)* | O(n) | O(n) | O(1) | contiguous ring buffer |
FastPriceLevel |
O(1) | O(1) | O(1) | O(1) | per-node heap allocs, pointer chasing |
SlabPriceLevel |
O(1)* | O(1) | O(1) | O(1) | arena, slot reuse, index links |
* amortized
DefaultStorage (src/storage/default_storage.rs) keeps the two sides in ordered maps so the best price is always the first key:
bids: BTreeMap<Reverse<Price>, P>, // Reverse: highest bid first
asks: BTreeMap<Price, P>, // lowest ask first
order_lookup: FxHashMap<OrderId, (Price, Side)>, // id -> where it restsThe Reverse<Price> trick gives both sides the same "best = first_key_value()" access pattern. The side-wide order_lookup map is what lets a cancel/modify jump straight to the right level without searching. The Storage trait also serves depth queries (get_depth) for snapshots, so alternative backends (e.g. a flat array of price buckets) could be dropped in without touching the book logic.
Matching policy is deliberately decoupled from the data structures: a MatchingEngine (src/matching_engine/mod.rs) is handed one price level and a quantity, fills against it, and reports what happened. It also owns the priority rule for modifies (loses_priority).
FifoEngine (fifo.rs) — price-time priority: peel orders off the front of the queue, emitting a Fill per maker; the last maker touched may be partially filled in place.
ProRataEngine (pro_rata.rs) — size-proportional allocation, the policy used in many futures/options markets. Points of interest:
- Sweep fast path: if the incoming size covers the whole level, skip the math and drain the queue.
- Two-phase fill: a read-only pass computes every maker's share (
incoming * maker_size / level_total, inu128to dodge overflow), then a mutating pass applies them by id — sidestepping iterator-invalidation entirely. - Remainder distribution: integer division under-allocates, so leftovers are handed out one contract at a time from the front (FIFO tiebreak), capped by each order's headroom.
- Scratch buffer reuse: the allocation table lives in a
Vecowned by the engine and isclear()ed, not reallocated, on every fill.
src/wal/mod.rs is a small, generic, segmented WAL used by both the book and the ledger:
- Record format:
[u32 big-endian length][bincode payload], where the payload is(timestamp_nanos, T)for anyT: bincode::Encode. - Segmented files:
1.log,2.log, … in a directory; a segment rotates past 1 MiB (tracked with a byte-countingBufWriterwrapper, so nostatcalls on the hot path). - Flush policy: buffered writes with a periodic flush every 1 ms, plus flush-on-drop for clean shutdown — a deliberate latency/durability trade.
- Replay: a lazy iterator walks the sorted segments and decodes records one at a time;
OrderBookBuilder::with_replayfeeds them back throughprocess_order_at(order, ts), reusing the original timestamps so recovery is deterministic. Encode buffers are reused across appends.
The WAL round-trip is tested by writing a real 10k-order fixture through the writer, reopening the directory, and asserting every (ts, order) pair replays byte-for-byte identical.
src/accounts/mod.rs implements a minimal clearing ledger for a two-asset market (money vs shares). Each user holds four balances:
pub struct Balance {
pub money_free: u64, pub money_escrow: u64,
pub shares_free: u64, pub shares_escrow: u64,
}The lifecycle mirrors a real exchange:
- Authorize (read-only, before the book sees the order): does the user exist, do they have the free balance, do they own the order they're cancelling/modifying? Market buys are authorized against the current best price.
- Escrow on accept: a resting bid locks
size × pricemoney; a resting ask lockssizeshares (free → escrow). - Settle on fill: for each
Fillthe maker's escrowed asset moves to the taker and payment flows back — both legs settled atomically on the engine thread. - Release on cancel/modify: escrow is returned or adjusted by the delta.
Accounts also maintains order_owner: OrderId → UserId (how a fill finds the maker to pay) and per-user open-order sets (served in user snapshots). Every mutation is expressed as an AccountCommand, appended to the accounts WAL, and replayed on startup — the same event-sourcing pattern as the book.
src/engine/actor.rs is where the design commits to the classic exchange architecture: one thread owns everything, everyone else sends messages.
spawn_engine_actormoves theOrderBookandAccountsonto a dedicated OS thread (lob-engine) that loops onblocking_recv— matching never touches the async runtime and never takes a lock.- Axum handlers talk to it via a bounded
mpscchannel (8192) ofCommands, usingtry_send: if the engine falls behind, submission fails fast instead of queueing unboundedly — backpressure by construction. Request/response commands carry aoneshotreply channel. - Each
SubmitOrderruns the full pipeline atomically with respect to other commands: authorize → match → settle → publish. There is no window where the book and the ledger disagree. - Market data out: the engine serializes each
DepthDeltato JSON once, wraps it asArc<Frame { seq, json: Bytes }>, and pushes it into atokio::broadcastchannel. A thousand WebSocket subscribers cost a thousandArcclones, not a thousand serializations. - Private events out:
UserChannels(src/engine/user_events.rs) is a registry of per-user broadcast channels, created lazily on first subscribe, reference-counted, and removed when the last subscription drops — emitting to a user with no open socket is a no-op, so idle users cost nothing. Fills emit both legs: the maker getsrole: Maker, the takerrole: Taker, and every affected user receives a closingBalanceUpdatedfor that sequence number.
src/server/ wires the engine to the outside world with axum:
- Auth (src/auth.rs): stateless HMAC-SHA256 signed tokens.
POST /usersreturnsbase64url(user_id ‖ issued_at ‖ HMAC(payload)); anAuthedUserextractor verifies the signature (constant-time) and injects theUserIdinto handlers. Tokens ride inAuthorization: Bearer …or, for WebSocket clients that can't set headers,?token=…. - Two submission paths:
POST /orderawaits the engine's reply (you learn your order id / rejection synchronously);POST /order_fafis fire-and-forget — enqueue and return202immediately,503if the engine queue is full. Rejections still reach the user asynchronously asOrderRejectedevents on their private stream. - Market WebSocket: on connect the handler subscribes first, then requests a snapshot — the ordering that guarantees no gap — sends the full
DepthSnapshot, then streams deltas, discarding any withseq ≤ snapshot.seq(dedup instead of gap). A client that lags the broadcast buffer is disconnected (slow-consumer policy) and is expected to reconnect for a fresh snapshot. Idle sockets are reaped after 15 s without traffic. - User WebSocket: same snapshot-then-delta pattern for private state — a
UserSnapshot(balances + open orders + seq) followed by the liveUserEventstream. - Ops: structured
tracingwith#[instrument]spans throughout; the server binary logs human-readable output to stdout and JSON to daily-rolling files in./logsvia a non-blocking appender; CORS configured for a local UI.
Orders use serde's externally-tagged enum encoding:
| Endpoint | Auth | Description |
|---|---|---|
POST /users |
– | Create a user with demo balances; returns {user_id, token, …} |
POST /order |
Bearer | Submit and await the result; 202 + order id (or null for market orders), 4xx on rejection |
POST /order_faf |
Bearer | Fire-and-forget submit; 503 when the engine is saturated |
GET /ws/market |
– | Public depth: one DepthSnapshot, then DepthDelta frames |
GET /ws/user?token=… |
token | Private stream: one UserSnapshot, then UserEvents |
# create a user, submit a resting bid, then lift the ask side
TOKEN=$(curl -s -X POST localhost:3001/users | jq -r .token)
curl -s -X POST localhost:3001/order \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"Limit":{"price":10000,"size":10,"side":"Bid"}}'
curl -s -X POST localhost:3001/order \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"Market":{"size":3,"side":"Bid"}}' # consumes bid liquidity (a sell)Sample private stream after a fill:
{"type":"Fill","seq":7,"order_id":3,"price":10000,"qty":3,"side":"Bid","role":"Maker"}
{"type":"BalanceUpdated","seq":7,"balance":{"money_free":970000,"money_escrow":70000,"shares_free":1000003,"shares_escrow":0}}Criterion benchmarks (benches/lob_bench.rs) replay pre-generated order flow through a fresh book and compare all three price-level implementations on two workload shapes:
- Mixed flow (
generate_orderflow.py): a mean-reverting random walk around a mid price, ~60% limits / 15% markets / 15% cancels / 10% modifies, log-normal sizes, depth spread over ~40 ticks — a "normal market" where levels stay shallow. - Single-level stress (
generate_single_level.py): seed up to 200k limit orders within ±2 ticks of one focal price, then hammer that level with 40% cancels / 25% modifies / 20% markets / 15% limits. This is the adversarial case for theVecDequebaseline: cancels by id in the middle of a deep queue.
python3 generate_orderflow.py 1000000 data/bench_orders_1M.json
cargo benchMeasured results, 50k-operation datasets on an Apple M5 Pro (mean whole-dataset replay time, lower is better):
| workload | SlowPriceLevel |
FastPriceLevel |
SlabPriceLevel |
|---|---|---|---|
| mixed flow, 50k ops | 5.43 ms | 5.51 ms | 4.95 ms |
| single-level stress, 50k ops | 5.62 ms | 3.66 ms | 3.41 ms (~68 ns/op) |
The results make the trade-offs concrete:
- On mixed flow the naive
VecDequebaseline is not slow — levels stay shallow, so its O(n) cancel scans a handful of elements in contiguous memory, and it actually edges out theRc<RefCell>linked list, which pays allocation and pointer-chasing costs on every operation without deep queues to amortize them. - On the single-level stress test the O(1) structures win decisively (~1.6×), because cancels and modifies land in the middle of a queue thousands of orders deep.
- The slab arena beats the
Rclist on both workloads — same asymptotics, better memory layout and no per-node allocation.
In short: asymptotics start mattering when queues get deep, and memory layout matters everywhere. Making that visible is the main thing this project was built for.
cargo test runs the full suite (66 tests):
- Per-implementation level tests: all three price levels pass an identical battery — cancel front/middle/back, modify with and without reprioritization,
total_contractsconsistency through mixed operation sequences, zero-size and duplicate-id rejections. - Property-based tests (proptest):
total_contractsequals the sum of random order sizes for arbitrary insert sequences. - Memory-safety tests: the
Rc-based linked list is checked for leaks by downgrading node handles toWeakand asserting they fail to upgrade after every pop/remove path — proving noRccycle survives. - Book-level integration tests: market orders sweeping multiple levels, partial fills, base-info (best bid/ask/spread) maintenance, and the priority semantics of modify (size-down keeps queue position and is filled first; size-up loses it).
- WAL round-trip: a real 10k-order fixture written, reopened, and replayed record-for-record.
cargo test # full test suite
cargo bench # criterion comparison of the three price levels
cargo run --release --bin lob-server # HTTP/WS gateway on 0.0.0.0:3001
# WALs land in ./book_wallog and ./accounts_wallog and are replayed on restart —
# kill the server, start it again, and the book + balances come back.
cargo run --release --bin lob data/bench_orders_50k.json # offline replay of a JSON order-flow filesrc/
├── core/ # domain types: Price, Side, Order, OrderUpdate, Ack, depth types
├── order_book.rs # OrderBook<S, M>: validation, sequencing, WAL hook, builder
├── price_level/
│ ├── slow_price_level/ # VecDeque + HashSet baseline (O(n) cancel)
│ ├── fast_price_level/ # Rc<RefCell> doubly-linked list + id→node map (O(1))
│ └── slab_price_level/ # slab-arena linked list, index links (O(1), default)
├── storage/ # Storage trait + BTreeMap-based DefaultStorage
├── matching_engine/ # MatchingEngine trait, FifoEngine, ProRataEngine
├── wal/ # segmented length-prefixed bincode WAL: writer, reader, replay
├── accounts/ # balances, escrow, authorization, settlement, accounts WAL
├── engine/ # engine actor thread, Command/EngineHandle, per-user channels
├── server/ # axum router, handlers (REST + WS), app state
├── auth.rs # HMAC-SHA256 token signing/verification, AuthedUser extractor
└── bin/
├── lob-server.rs # full server: WAL replay + accounts + gateway
└── lob.rs # offline order-flow replayer
benches/lob_bench.rs # criterion: 3 price levels × 2 workload shapes × N sizes
generate_orderflow.py # mixed-market dataset generator
generate_single_level.py # single-level stress dataset generator
Deliberate simplifications, in the spirit of keeping the learning surface honest:
- Crossing limit orders are rejected, not matched — the book is maker-only and all taking happens via market orders. Supporting marketable limits (match the crossing part, rest the remainder) is the natural next step.
- Single instrument — there is no symbol dimension; scaling out would mean one engine actor per instrument.
- Market-order authorization uses the best price only, so a sweep through deeper, worse-priced levels can exceed the amount pre-authorized (fine for a bid sweep where price only improves the buyer; imprecise for the general case).
FastPriceLevel::iter()is a stub (returns an empty iterator), so the pro-rata engine — which iterates a level — should be paired with the slab or slow implementations.- WAL flushes are buffered, not fsync'd — durability is "flushed to the OS every millisecond", not power-loss-proof.
- Auth tokens never expire and there is no rate limiting; auth here demonstrates the stateless-HMAC pattern, not a hardened perimeter.
- Prices and balances are plain
u64ticks — no fixed-point/decimal layer.
{"Limit": {"price": 10000, "size": 10, "side": "Bid"}} {"Market": {"size": 5, "side": "Ask"}} // side = book side consumed: Ask ⇒ buy {"Cancel": {"order_id": 42}} {"Modify": {"order_id": 42, "new_size": 25}}