Price-Time Priority Matching
The heart of every exchange, in under 300 lines.
Foundation · Concurrency, Memory & the Core Engine
Why this week
Last week's book stores orders. This week it starts trading them. A matching engine is a small piece of code with an unforgiving contract: for every incoming order, decide exactly which resting orders it trades with, at which prices and for how much, and announce the result as a sequence of events that every downstream system — market data, risk, clearing, the client's own order manager — will treat as the truth.
The rules fit on an index card: best price first, then oldest order first, every trade at the resting order's price. The engineering is in everything around them. Partial fills must leave the book exactly right. Events must come out in an order consumers can rebuild state from. And the whole thing must be deterministic: the same input stream must produce the same events, bit for bit, on every run and every machine, because week 9 rebuilds the exchange by replaying its input and week 20 runs a hot standby that must agree with the primary.
You'll write the matching loop, then the event-emitting engine around it, and in the repo you'll put both on top of the w06 book with a p99 budget of under half a microsecond per order.
Day 1 — The rules of price-time priority
Who trades with whom, at what price
Crossing, priority and the trade price
In 'Order-Driven Markets', read the sections on order precedence rules (price priority first, then secondary rules such as time, display and size) and on how trade prices are set in continuous trading. You will implement exactly one combination: price, then time, at the standing order's price.
An incoming order is marketable (it crosses the spread) when it can trade immediately: a buy priced at or above the best ask, a sell at or below the best bid. A market order is always marketable while the other side has liquidity. The engine then:
- Takes the best opposite level (lowest ask for a buy, highest bid for a sell), as long as it still crosses the incoming order's limit.
- Trades against that level's orders oldest first, each trade for
min(incoming remaining, resting remaining). - Prices every trade at the resting order's price. A buy limit at 105 that meets an ask at 100 trades at 100: the aggressor gets the price improvement, and the resting order gets exactly what it asked for.
- Removes fully filled resting orders, leaves a partially filled one at the front of its queue with its time priority intact, and moves to the next level when one empties.
- Stops when the incoming order is filled, or the next level no longer crosses, or the side is empty. What is left over rests on the book (limit) or is cancelled (market).
The resting order is the maker (it provided liquidity), the incoming one the taker. Many venues charge them different fees, which is why the distinction appears in every trade record.
Submit an order that crosses the spread to see trades.
Not every market uses time as the second rule. Some interest-rate futures have historically used pro-rata allocation, where a level's fill is split in proportion to resting size, sometimes with a priority slice for the first order or for designated market makers. Pro-rata rewards size rather than speed and changes trading behaviour completely. Your exchange is FIFO; know that the alternative exists and why.
A buy limit order for 10 at 105 arrives. The asks are 3 at 100 and 20 at 104. What trades happen, at what prices, and what is left?
Day 2 — The matching loop
Walk levels, walk queues, stop exactly
Drill: match an aggressive limit order
The core loop has two nested walks: across price levels in priority order, and within a level across orders in time order. The common bugs are all at the boundaries:
- comparing with
<where the rule is<=(an order at exactly the best ask does cross); - forgetting to remove a level once its last order is gone, so the next best-price read returns an empty level;
- removing a partially filled maker (it must stay at the front with reduced quantity);
- using the incoming limit as the trade price.
BTreeMap::first_entry and last_entry give you a mutable handle on the best level that you can also remove() through, which keeps the loop free of repeated lookups.
Match an aggressive limit order
use std::collections::{BTreeMap, VecDeque};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side {
Buy,
Sell,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Fill {
pub maker_id: u64,
pub price: i64,
pub qty: u64,
}
/// Resting orders: price → FIFO of (order id, remaining qty), oldest first.
pub struct Book {
pub bids: BTreeMap<i64, VecDeque<(u64, u64)>>,
pub asks: BTreeMap<i64, VecDeque<(u64, u64)>>,
}
impl Book {
pub fn new() -> Self {
Book { bids: BTreeMap::new(), asks: BTreeMap::new() }
}
/// Place a passive order at the back of its level (no matching).
pub fn rest(&mut self, side: Side, id: u64, price: i64, qty: u64) {
let levels = match side {
Side::Buy => &mut self.bids,
Side::Sell => &mut self.asks,
};
levels.entry(price).or_default().push_back((id, qty));
}
/// Match an incoming limit order against the opposite side.
/// Buy: take asks from the lowest price up, while ask ≤ limit.
/// Sell: take bids from the highest price down, while bid ≥ limit.
/// Within a level, oldest first. Every fill prints at the maker's
/// (resting) price. Fully filled makers leave the book; a level with no
/// orders left is removed from the map.
/// Returns the fills in order and the unfilled remainder (NOT rested).
pub fn match_order(&mut self, side: Side, limit: i64, qty: u64) -> (Vec<Fill>, u64) {
todo!()
}
}
The drill uses VecDeque levels to keep the focus on the rules. In the repo you run the same loop over the w06 book through its trait: front(side) is the oldest order at the best price, and fill_front(side, qty) fills it, removing the order once it is done and the level once it empties, so the engine never touches the level map.
Day 3 — Events are the product
Execution reports and event order
What every downstream consumer needs to rebuild state
Open FIX 4.4, message ExecutionReport (MsgType 35=8). Read ExecType (150), OrdStatus (39), OrderQty (38), CumQty (14), LeavesQty (151), LastQty (32) and LastPx (31). Every execution report a venue sends is a view of the internal events you design today.
The engine's output is a stream of events, and the rest of the exchange is built from it: execution reports to the two clients involved, the public market-data feed (week 10), position and risk updates (weeks 15 and 45), and the audit trail. Your internal events can be much simpler than FIX, but they must carry enough to produce all of those views.
This week's event set:
Accepted { id }— the engine took responsibility for the order.Trade { taker, maker, price, qty }— one per maker touched, in the order they traded. One event names both sides, so the maker's fill report and the taker's fill report are derived from the same fact and can never disagree.Rested { id, side, price, qty }— the unfilled remainder of a limit order joined the book.Cancelled { id, qty }— quantity left the book or never reached it: a market order's unfilled remainder, or an explicit cancel of a resting order.
For each submitted order the sequence is fixed: Accepted, then zero or more Trades, then at most one of Rested or Cancelled. That shape is what makes the stream checkable.
The repo's types::Event is the same design with a few more cases. The unfilled remainder of a market or IOC order is Expired { id, remaining }, and Cancelled { id, remaining } is kept for an explicit cancel of a resting order. Trade also carries taker_side, and Rejected { id, reason } and Reduced { id, remaining } cover the other outcomes. So in the repo a new order produces either a lone Rejected, or Accepted, then Trades, then at most one Rested or Expired. Its central invariant is conservation of quantity: for every order, quantity traded plus quantity rested plus quantity cancelled equals quantity submitted — the same invariant as FIX's CumQty + LeavesQty = OrderQty for a working order.
Why must Rested come after the Trade events for the same incoming order, and not before?
State the conservation invariant your engine's events must satisfy for each order, and its FIX equivalent.
Day 4 — The event-emitting engine
Limit, market and cancel
Drill: emit the exact execution event sequence
Now wrap matching in an engine that owns the book and speaks only in events. The drill adds market orders (which never rest; their remainder is Cancelled) and explicit cancels of resting orders (an id that is not resting — unknown, already filled or already cancelled — produces no events in the drill; in the repo it is already a typed reject this week, Rejected { id, reason: UnknownOrder }).
The harness checks exact event lists for scripted scenarios, then runs 3,000 random orders and cancels twice and requires identical streams, and replays your events into a shadow book built only from the events: every trade must hit an order that was resting at that price with enough quantity, quantity must be conserved for every order, and the shadow book must never be crossed.
Emit the exact execution event sequence
use std::collections::{BTreeMap, HashMap, VecDeque};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side {
Buy,
Sell,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum OrderType {
Limit(i64),
Market,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Event {
Accepted { id: u64 },
Trade { taker: u64, maker: u64, price: i64, qty: u64 },
Rested { id: u64, side: Side, price: i64, qty: u64 },
Cancelled { id: u64, qty: u64 },
}
pub struct Engine {
bids: BTreeMap<i64, VecDeque<(u64, u64)>>,
asks: BTreeMap<i64, VecDeque<(u64, u64)>>,
/// id → (side, price) of every resting order, so cancel can find its level.
resting: HashMap<u64, (Side, i64)>,
}
impl Engine {
pub fn new() -> Self {
Engine { bids: BTreeMap::new(), asks: BTreeMap::new(), resting: HashMap::new() }
}
/// Process one new order; return every event it caused, in this order:
/// 1. Accepted { id }
/// 2. one Trade per maker touched, in price-time order, at the maker's price
/// 3. then exactly one of:
/// Rested { .. } a limit order with quantity left joins the book
/// Cancelled { .. } a market order with quantity left (it never rests)
/// nothing fully filled
/// Makers that are fully filled leave the book (and `resting`).
pub fn submit(&mut self, id: u64, side: Side, kind: OrderType, qty: u64) -> Vec<Event> {
todo!()
}
/// Cancel a resting order: `[Cancelled { id, qty: remaining }]`, or `[]`
/// if `id` is not resting (unknown, already filled or already cancelled).
/// Scanning the one level for the id is fine here — week 6 made it O(1).
pub fn cancel(&mut self, id: u64) -> Vec<Event> {
todo!()
}
}
A market order on an empty or thin book trades at whatever price is there, however far away. Real venues protect against this with price collars or by converting market orders into limit orders a bounded distance from a reference price. Week 8 adds price bands; week 15 adds pre-trade risk checks. Until then, your engine will happily sweep the book.
Day 5 — Determinism
Same input, same output, every time
Single writer, no clocks, no hidden order
A single-threaded business logic processor holding all state in memory, fed by an input stream that is journaled and replicated before processing. Read it for the argument that one thread, no locks and deterministic processing are what make an exchange both fast and recoverable.
Determinism is a property you design in, not one you test in afterwards. The engine is a pure function of its input sequence if — and only if — nothing else leaks in:
- One thread per book. The sequencer's order is the processing order; there is no interleaving to vary.
- No clocks inside matching. If an event needs a timestamp, it is assigned when the command enters the sequenced stream and carried with it.
Instant::now()inside the engine makes every replay different. - No iteration over
HashMapin anything that produces output. Std'sHashMapuses a randomly seeded hasher, so iteration order differs between runs and processes. Use it for lookups; iterateBTreeMaps,Vecs or the book's own ordered structures. - No floating point in prices or quantities, and no randomness that is not itself an input.
- No dependence on addresses, allocation order or thread scheduling.
List four ways nondeterminism can leak into a single-threaded matching engine.
A firm's buy order arrives and the best ask belongs to the same firm. Should the engine let it trade with itself? Sketch the options (cancel the resting order, cancel the incoming order, cancel both, decrement both) and how each shows up in your event stream.
Day 6 — The shape of a fast engine
A microsecond per order
Where the time goes, before you profile
Before profiling anything (week 26), you can predict most of the cost from structure:
- Allocation. Returning a fresh
Vec<Event>per order allocates on every call. Have the caller pass a reusable buffer (submit(&mut self, order, out: &mut Vec<Event>), cleared by the caller) and the steady state allocates nothing. - Lookups. A passive order costs a level lookup and an index insert; an aggressive one touches the best level, the front nodes and the index entries of every maker it fills. Each is a potential cache miss; with 100,000 resting orders, the index and the slab do not fit in L1 or L2.
- Branches. Most orders in a realistic flow do not cross. Make the "does it cross?" check the first thing the hot path does, so passive orders skip the matching loop entirely.
- Event size. Keep events small and
Copy: the engine writes them, the journal and the market-data publisher read them, and every byte is copied at least twice.
Write the signature of your engine's submit and cancel functions as they will exist in the repo: inputs, output buffer, error type. How does the caller learn about rejects (unknown id, duplicate id, zero quantity) without the hot path allocating or panicking?
Capstone
Build crates/engine on top of the w06 book.
What to build. Engine<B: OrderBook= Book> in src/engine.rs: an engine that owns one book (Engine::new(spec), or Engine::with_book(config, book) for any OrderBook) and turns types::Commands into types::Events. process(&cmd, &mut out) dispatches to submit, cancel and reduce, and each one appends its events to the caller's buffer in the order the crate docs fix: for a new order Accepted, then the Trades, then at most one Rested (a GTC limit's remainder) or Expired (a market or IOC remainder), or a lone Rejected; Cancelled { remaining } or Reduced { remaining } for the other commands. Keep the matching loop on the book's own operations (front, fill_front) rather than reaching into its internals. Also implement seq() (every command counts, rejected ones included), last_trade_price() and state_hash(): a deterministic hash of the sequence number, the last trade price and every resting order in priority order, built with the provided StateHasher.
Key decisions. How rejects surface. In the repo they are events from the start: a new order reusing the id of a live order is Rejected(DuplicateOrderId), a market order on an empty side is Rejected(NoLiquidity), a cancel or reduce of an id that is not resting is Rejected(UnknownOrder), and a reduce must lower the quantity (ZeroQuantity, InvalidReduce). The w06 BookErrors convert with .into(); week 8 adds the rest of the validation. Whether Trade carries both order ids (it does, plus the taker's side) and whether you also emit a per-side fill event for convenience (the repo does not: makers get no event of their own, and consumers derive maker fills from Rested and Trade). How you guarantee events are emitted for every state change, so that a consumer replaying only events always rebuilds the same book. The repo writes that rule down as the event-order contract in the engine crate docs; week 10's market-data builder depends on it.
Tests. tests/w07_matching.rs runs scripted scenarios with exact expected event lists: resting, the maker's price, sweep priority, partial fills, market orders that sweep and expire, cancel and reduce, and duplicate ids. It then feeds a 3,000-command random stream to two engines and to an Engine<Box<dyn OrderBook>>, which must produce identical events and state hashes and never leave the book crossed. A shadow-book replay that checks conservation of quantity and that no trade hits a non-resting order arrives with week 12's property tests; write your own now if you want it sooner.
Benchmarks. w07.match_latency is the p99 of "process one command, get its events" under a mixed flow that keeps the book around 1,000 resting orders over about 50 levels a side. Of the new orders about 70% are passive and 30% aggressive (limits up to 2 ticks through the touch, IOC or GTC, plus some market orders), and cancels of live orders make up about 30% of all commands. That is the realistic shape, where most calls never enter the matching loop. w07.engine_throughput runs a million commands of the same flow on one thread; its target of 6 million per second is an average budget of under 170 ns per command, cancels included. The benchmark generates the stream before the timed loop (random generation is not free), reuses the event buffer and reports p99.9 alongside p50 and p99: a few slow level creations or rehashes are exactly what the tail will show.
crates/engineThe matching engine
Limit and market orders, partial fills, typed rejects, and an ordered Accepted / Trade / Rested / Expired / Cancelled event stream over the w06 book.
cargo xtask test --week 7 # tests named w07_*
cargo xtask bench --week 7 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 7 tests pass w07_*
- Submit→events, mixed flow (70% passive) w07.match_latencytarget p99 ≤ 450 ns
- Orders per second, single thread w07.engine_throughputtarget ≥ 6.0M ops/s
Feeds into
- Week 8 adds time-in-force (IOC, FOK), post-only and validation in front of this engine, turning bad input into typed rejects.
- Week 9 journals the engine's input and proves that replaying it reproduces the same state, which only works because of today's determinism rules.
- Week 10 turns these events into the public L2 market-data feed.