Order Book Data Structures
Price levels, FIFO queues, and O(1) cancels.
Foundation · Concurrency, Memory & the Core Engine
Why this week
Every exchange, every market maker and every feed handler keeps a limit order book. It is the most important data structure in trading, and it is usually designed from habit: a sorted map of price to a list of orders, because that is what the textbook shows. This week you design it from the operation mix instead.
That mix is lopsided. On busy instruments the large majority of orders are cancelled rather than executed (cancel-to-trade ratios of 10:1 and far higher are normal for electronically quoted names), almost all activity happens within a few ticks of the best prices, and the best bid and ask are read on every single incoming order. So the operations that must be fast are cancel by id, add at an existing level near the touch and read the best price. Creating a brand-new level far from the market and walking deep into the book are comparatively rare.
You'll build the two pieces that make those operations cheap — a price level that is a FIFO queue with O(1) removal from the middle, and a structure that keeps the best price current as levels empty — and then, in the repo, the full book your matching engine runs on next week.
Day 1 — What a book does all day
Orders, levels and the touch
The operations before the data structure
Read the chapters 'Orders and Order Properties' and 'Order-Driven Markets'. You want the vocabulary (limit order, the touch, depth, order precedence rules) and the trader's view of why price and then time priority is the norm in continuous order-driven markets.
Skim the order messages: Add Order, Order Executed, Order Cancel (partial), Order Delete and Order Replace. That list is exactly the set of operations a book fed by a real exchange must support, and every one of them identifies the order by its reference number — which is why cancel-by-id has to be fast.
The vocabulary, in one paragraph: a limit order rests on the book at a price. All resting orders at one price form a price level. The highest bid and the lowest ask are the best bid and best ask, together the touch or top of book; the gap between them is the spread. Levels further away are the book's depth. In a price-time priority market, an incoming order trades first against the best price, and within a price against the oldest order.
Submit an order that crosses the spread to see trades.
Play with the sandbox: add a few orders at several prices, cancel one from the middle of a level, and send an aggressive order through. Watch which structures each operation touches.
Name the three book operations that dominate a real exchange's workload and the complexity each should have in a good design.
Day 2 — Prices as integers, levels as a map or a ladder
Choosing the level structure
BTreeMap, sorted Vec, or dense ladder
Prices are integers. Every venue defines a minimum price increment, the tick (a cent for most US stocks priced at $1 or more; a fixed fraction of a point for each futures contract). Store prices as a count of ticks, or as a fixed-point integer in a unit smaller than any tick, in an i64. Never f64: 0.1 + 0.2 != 0.3, equality on floats breaks map lookups, and rounding differs between code paths. The repo's types crate already wraps it as Price(i64) (and quantities as Qty(u64)); week 8 adds the validation: tick size, lot size and price bands.
Levels. Three serious designs:
BTreeMap<Price, Level>per side. O(log n) to find or create a level, ordered iteration for depth, best price viafirst_key_value/last_key_value. B-tree nodes are arrays (up to 11 keys per node in the current std implementation), so it is far more cache-friendly than a red-black tree. The default choice, and a fine one.- Sorted
Vec<Level>with the best price at the end. Store bids ascending and asks descending. Most adds, cancels and fills happen near the touch, which is now near the end of the vector, so inserting or removing a level moves only a few elements. Best price islast(). Pathological when activity lands deep in a long vector, excellent otherwise. - Dense ladder:
Vec<Level>indexed byprice - base. O(1) to find any level, no comparisons, perfectly predictable memory access. The costs: memory proportional to the price range rather than to the number of levels, a scan to find the next best level when the best empties, and re-centring when the market drifts outside the window. For a $100 stock with a one-cent tick, a ±10% window is about 2,000 levels per side; at 8 bytes of quantity per level that is 16 KB, which fits in L1.
The dense ladder makes finding a level O(1). Which operation does it make worse than a BTreeMap, and why is that usually acceptable?
Your exchange will list instruments with very different profiles: a $5 stock that trades within a few cents all day, a $3,000 stock, and a futures contract whose tick is large relative to its daily range. Which level structure would you pick for each, and what would make you switch?
Day 3 — A price level is a FIFO with holes
Intrusive lists over a slab
Drill: O(1) append, front, fill and cancel
Inside one level, orders must come out oldest first (time priority), which suggests a queue. But a cancel removes an order from the middle of the queue, and a VecDeque can only do that by shifting everything behind it — O(n) per cancel, on the operation that dominates the workload.
The standard answer is an intrusive doubly linked list: each order node carries prev and next links, so unlinking any node is O(1) given the node. Two changes make it fast in Rust instead of a borrow-checker fight:
- Store nodes in a slab, a
Vec<Node>plus a free list of vacant slots, and link them byu32index rather than by pointer. No allocation per order once the slab is warm, noRc<RefCell<…>>, and a node withu32links is small: id, quantity,prev,nextfit in 24 bytes. - Index orders by id: a map from order id to slot is what turns "cancel order 12345" into an O(1) unlink. A partial fill of the front order changes its quantity in place and keeps its position; only removal unlinks.
struct Node { id: u64, qty: u64, prev: u32, next: u32 } // 24 bytes, u32::MAX = none
Price level with O(1) cancel
use std::collections::HashMap;
pub type OrderId = u64;
const NIL: u32 = u32::MAX;
#[derive(Clone, Copy, Debug)]
struct Node {
id: OrderId,
qty: u64,
prev: u32,
next: u32,
}
/// Every resting order at one price, oldest first. Nodes live in a slab
/// (`nodes` + a `free` list of reusable slots) and link to each other by
/// index; `index` maps an order id to its slot so cancel never searches.
pub struct PriceLevel {
nodes: Vec<Node>,
free: Vec<u32>,
index: HashMap<OrderId, u32>,
head: u32,
tail: u32,
total_qty: u64,
}
impl PriceLevel {
pub fn new() -> Self {
PriceLevel { nodes: Vec::new(), free: Vec::new(), index: HashMap::new(), head: NIL, tail: NIL, total_qty: 0 }
}
/// Append at the back (lowest time priority). Reuse a free slot if there
/// is one. Returns false (and changes nothing) if `id` is already here.
pub fn push_back(&mut self, id: OrderId, qty: u64) -> bool {
todo!()
}
/// Unlink `id` from wherever it sits, in O(1). Returns its remaining qty.
pub fn cancel(&mut self, id: OrderId) -> Option<u64> {
todo!()
}
/// The oldest order — the next one to trade.
pub fn front(&self) -> Option<(OrderId, u64)> {
todo!()
}
/// Trade up to `qty` against the front order (a fill). A partial fill
/// keeps the order at the front; a full fill removes it. Returns the
/// quantity actually filled (0 on an empty level).
pub fn fill_front(&mut self, qty: u64) -> u64 {
todo!()
}
pub fn len(&self) -> usize {
self.index.len()
}
pub fn total_qty(&self) -> u64 {
self.total_qty
}
/// Slots ever allocated in the slab — freed slots must be reused.
pub fn slots_allocated(&self) -> usize {
self.nodes.len()
}
/// Order ids from oldest to newest (for tests; O(n) is fine here).
pub fn ids(&self) -> Vec<OrderId> {
todo!()
}
}
The last case cancels 50,000 orders out of 100,000 in random order and compares your per-cancel time with a plain HashMap remove measured in the same run. An implementation that walks the list to find the order fails it by two orders of magnitude.
Why link order nodes with u32 slab indices rather than boxed pointers or reference-counted cells?
Day 4 — Keeping the best price current
Best bid and ask in O(1)
Drill: a dense ladder with cached best levels
Reading the best price must be O(1) because the engine does it on every order. Writes are where the work is:
- An add can only improve the best: compare and maybe replace. O(1).
- A cancel or fill that leaves the best level non-empty changes nothing. O(1).
- A cancel or fill that empties the best level is the one case that needs work: find the next non-empty level. In a BTreeMap that is the next key; on a ladder it is a scan away from the touch.
Today's drill is the ladder version, because it forces you to handle the edges: a level at index 0, a side that empties completely, an add after a side was empty. The harness runs 20,000 random operations and compares your best bid and ask with a BTreeMap model after every one.
Maintain best bid/ask under adds and cancels
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side {
Bid,
Ask,
}
/// Aggregate quantity per price on a dense ladder of `levels` ticks:
/// index i ↔ price `min_price + i` (prices are integer ticks).
pub struct Ladder {
min_price: i64,
bids: Vec<u64>,
asks: Vec<u64>,
/// Index of the highest non-empty bid level.
best_bid: Option<usize>,
/// Index of the lowest non-empty ask level.
best_ask: Option<usize>,
}
impl Ladder {
pub fn new(min_price: i64, levels: usize) -> Self {
Ladder { min_price, bids: vec![0; levels], asks: vec![0; levels], best_bid: None, best_ask: None }
}
/// Add `qty` at `price` (always inside the ladder). O(1).
pub fn add(&mut self, side: Side, price: i64, qty: u64) {
todo!()
}
/// Remove `qty` at `price` — a cancel or a fill; never more than the
/// level holds. O(1), except when the best level empties: then walk
/// away from the touch to the next non-empty level (or None).
pub fn remove(&mut self, side: Side, price: i64, qty: u64) {
todo!()
}
/// (price, aggregate qty) of the best bid. O(1): no scanning here.
pub fn best_bid(&self) -> Option<(i64, u64)> {
todo!()
}
/// (price, aggregate qty) of the best ask. O(1).
pub fn best_ask(&self) -> Option<(i64, u64)> {
todo!()
}
pub fn qty_at(&self, side: Side, price: i64) -> u64 {
todo!()
}
}
Once it passes, think about the worst case of remove: a sparse book where the next bid is 1,500 slots away. A per-side occupancy bitmap (one bit per level, 64 levels per u64) turns that scan into a handful of word reads plus leading_zeros / trailing_zeros. Week 28 builds the full ladder book on that idea.
Day 5 — The order index, modify semantics and hashing
Id to location in one hop
And what 'modify' is allowed to keep
Why the default HashMap hasher (SipHash 1-3) is slow for small integer keys, and the faster alternatives (FxHash, ahash). Note the trade-off: SipHash resists hash-flooding attacks; most alternatives do not.
The order index maps an id to the order's location: its side, its price level and its slot. Three options, from general to specialized:
HashMap<OrderId, Handle>with the default hasher. Correct and DoS-resistant, but SipHash costs a noticeable fraction of your cancel budget for a singleu64key.- The same map with a fast non-cryptographic hasher (FxHash and friends). Several times cheaper per lookup. Safe here because the keys are not attacker-controlled: see the next point.
- Dense, exchange-assigned ids. The exchange assigns order ids, so it can make them dense (a sequence number), or embed the slab slot in the id itself (
slotin the low 32 bits, a generation counter in the high bits to catch stale ids). Lookup becomes an array index. The client's own order id (FIX ClOrdID) is mapped to the exchange id at the gateway, outside the engine's hot path.
Modify semantics decide what the book must support. The common rule across venues: reducing quantity keeps the order's time priority (it is in place, and it only hurts the person doing it), while increasing quantity or changing price loses priority — it is treated as a cancel and a new order at the back of the queue. Otherwise a trader could join a queue with 1 lot and grow to 10,000 while keeping a place they never paid for.
Why do exchanges let a quantity reduction keep time priority but make a quantity increase lose it?
Write the exact struct for a resting order node in your book. Which fields are on the matching hot path, which are cold? What is its size and how many fit per 64-byte line? Where do the client order id and account live?
Day 6 — Assembling the book
One API, two sides, many levels
Design the interface before the benchmark
A short, widely cited blog post describing a book built from a tree of price levels, a doubly linked list of orders per level and hash maps keyed by order id and by price, giving O(1) cancel and O(1) add at an existing level. Compare its choices with yours and note where you went further (slab storage, integer links).
Know first_key_value / last_key_value, range and range_mut for depth walks, and the entry API for create-or-append. The module docs explain why a B-tree beats a binary tree on modern caches.
Put the drills together into one structure per instrument:
- two level structures (bids and asks), each mapping a price to a level that holds the aggregate quantity, the order count and the head and tail of its FIFO;
- one slab of order nodes shared by all levels;
- one order index from id to slot (the node itself records side and price, so it can find its level);
- the cached best bid and best ask.
Keep the public surface small. In the repo it is given: the OrderBook trait in crates/book/src/lib.rs (add, cancel, reduce, lookup by id, best level, the front order and fill_front for matching, and visitors for depth), which your concrete Book implements. The engine is generic over that trait, so week 27 and week 28 can drop in a slab book and a ladder book behind the same interface and benchmark them against yours.
List the invariants your book must maintain after every operation (for example: every id in the index points at a live node; each level's aggregate equals the sum of its orders; no empty level is kept; best bid is below best ask once matching exists). Which can you check cheaply in debug builds after every operation?
Capstone
Build the order book in crates/book. The engine (week 7), the allocation-free slab rework (week 27) and the ladder book (week 28) all build on it, so the API matters as much as the speed.
What to build. A per-instrument Book in src/book.rs (Book::new(), Book::with_capacity(orders, levels)) that implements the provided OrderBook trait. add(RestingOrder) rests a new order at the back of its level, cancel(id) removes it from anywhere and returns it, and reduce(id, qty) lowers its quantity in place, keeping priority. The repo settles the increase question by rejecting it: reduce only lowers, an increase is BookError::InvalidReduce, and a trader who wants more size sends a new order, which joins the back of the queue. best(side) returns a LevelInfo (price, aggregate quantity, order count). front(side) and fill_front(side, qty) are the primitives week 7 matches with. visit_levels and visit_level walk the book best first without allocating, and the trait builds best_bid / best_ask, depth(side, n, &mut out) (the top n levels written into a caller-provided buffer, so the hot path does not allocate) and level_orders on top of them. Orders live in a slab with u32 links, the index maps id to slot, and each level tracks its aggregate quantity and order count so depth never walks orders.
Key decisions to write down. Which level structure. BTreeMap is the right first choice; keep the door open for the alternatives. The reference Book started on a BTreeMap and moved to the sorted Vec from Day 2 in week 26, after a profile of the matching flow put about 20% of engine time in B-tree searches for adds away from the touch and showed the tree allocating and freeing nodes as levels came and went. Which hasher, or whether your ids are dense enough to skip hashing (the crate provides IdMap, a hash map with a one-multiply hasher for OrderId keys). What happens on a duplicate id, a cancel of an unknown id and a zero-quantity add: the trait answers with typed errors (BookError::DuplicateId, UnknownOrder, ZeroQty, InvalidReduce) rather than panics, and the week 7 engine turns them into reject reasons through the provided From<BookError> for RejectReason.
Pitfalls. Removing an order must also remove its level when the level empties, or best-price queries will find an empty level. Aggregate quantities must be updated on partial fills as well as cancels. Iterating a HashMap anywhere that produces output makes the book non-deterministic, which week 9 will catch.
Benchmarks. w06.add_cancel fills a book with 100,000 orders spread uniformly over 500 ticks on each side of the mid (about 100 orders per level), then times one add at a random level plus one cancel of a random resting order elsewhere, and reports the p99 of the pair. The slab, the index and the levels are all larger than L1 and partly larger than L2, and the cancel lands on cold memory, so you are measuring cache misses as much as instructions. w06.best_price reads the best bid and the best ask on that book, with a target of 10 ns at p99. That is below the cost of reading the clock: Instant::now() costs roughly 15–30 ns through the vDSO on Linux, so the benchmark times batches of 64 queries and divides rather than timing each call.
crates/bookA limit order book with O(1) cancel
Add, cancel, modify-quantity, best bid/ask and top-N depth, price-time FIFO per level.
cargo xtask test --week 6 # tests named w06_*
cargo xtask bench --week 6 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 6 tests pass w06_*
- Add then cancel, 100k resting orders w06.add_canceltarget p99 ≤ 600 ns
- Best bid/ask query (timed over batches) w06.best_pricetarget p99 ≤ 10 ns
Feeds into
- Week 7 runs price-time matching on this book: aggressive orders consume the front of the best levels, partial fills update nodes in place.
- Week 27 makes the book allocation-free end to end, proving with a counting allocator that the slab design never touches the heap per order.