Order books and trading mechanics
An order book holds resting bids (buy orders) and asks (sell orders). It ranks each side by price, then arrival time: highest bid and lowest ask first, and at the same price, first come first served. An incoming order trades against the best opposite orders while its limit allows, at the resting order’s price, with partial fills, and whatever is left either rests on the book or is discarded. With price levels in a sorted structure, finding the best price is O(1) or O(log P) for P price levels.
The idea in plain words
Section titled “The idea in plain words”Picture a market stall with two queues. On one side, buyers hold up signs: “I’ll pay 101 for 200 units.” On the other, sellers: “I’ll sell 102 for 300.” Nobody trades yet, because the best buyer (101) won’t pay what the best seller asks (102). That gap is the spread.
Now a new buyer walks up shouting “I’ll pay up to 103 for 500.” They go to the seller with the lowest price first. If two sellers ask the same price, the one who’s been waiting longest goes first. The new buyer takes as much as that seller has, then moves to the next one, until they’ve bought 500 or the next seller wants more than 103. If they still need some, they stand in the buyers’ queue with a sign saying 103.
That’s the whole mechanism. The rest is vocabulary and edge cases.
| Term | Meaning |
|---|---|
| Bid / ask (offer) | A resting buy / sell order. The best bid is the highest; the best ask is the lowest. |
| Spread | Best ask − best bid. Positive in a normal market. |
| Mid | (best bid + best ask) / 2. A fair-value reference, not a tradable price. |
| Price level | All resting orders on one side at one price. Its size is their total quantity. |
| Depth | Size at each level, best first. “Top of book” is level 1. |
| Maker / taker | The resting order made liquidity; the incoming order took it. The taker is also called the aggressor. |
| Fill | One trade between one incoming and one resting order. One order can produce many fills. |
| Partial fill | A fill for less than an order’s remaining quantity. The order stays live for the rest. |
| Locked / crossed | Best bid = best ask (locked) or best bid > best ask (crossed). A single matching engine never stays crossed; a view across several venues can be. |
See it
Section titled “See it”Edit the resting book, then send an incoming order. Step through: it walks the opposite side best price first, oldest first within a price, trades at each resting price, and partially fills where quantities don’t line up. Try:
- The default buy 500 @ 103: it takes S1 (300 @ 102), then S3 (100 @ 102, later than S1), then 100 of S2 at 103, which stays partially filled. Nothing is left to rest.
- Raise qty to 900: the buy runs out of acceptable asks at 105 and rests the remaining 300 as a new bid at 103.
- Switch to IOC (the leftover is cancelled), FOK (all or nothing, checked before trading) and market (no limit; it walks into 105).
- Switch to a sell at 100 and watch it hit B1 before B3 (same price, B1 arrived first).
When to reach for it
Section titled “When to reach for it”| The problem says… | What it’s really asking |
|---|---|
| “match buy and sell orders”, “an exchange”, “a matching engine” | Price-time priority with partial fills, fills at the resting price. |
| “best bid / best ask”, “top of book”, “spread” | A max structure over bid prices and a min structure over ask prices. |
| “depth”, “top 5 levels”, “size at price” | Aggregated quantity per price level, iterated in price order: a sorted map. |
| “cancel by order id quickly” | A hash map from id to the order (or its position), next to the price structure. |
| “how much would it cost to buy 1,000 now” | Walk ask levels from the best, summing price × size: VWAP and slippage. |
| “bars”, “candles”, “OHLCV per minute” | Not the book: aggregating trades into time buckets (see aggregation and time). |
How it works
Section titled “How it works”The matching rules
Section titled “The matching rules”- An incoming buy looks at asks, lowest price first. It accepts an ask whose price is
<=its limit. A sell looks at bids, highest first, and accepts a bid>=its limit. A market order accepts any price. - Among resting orders at the same price, the earliest arrival goes first (FIFO).
- Each fill is for
min(incoming remaining, resting remaining)units, at the resting order’s price. - Remove resting orders that hit zero. Stop when the incoming order is filled or the next best price isn’t acceptable.
- Leftover: a plain limit (good-till-cancelled, GTC) order rests at its own price, at the back of that level’s queue. IOC (immediate-or-cancel) discards it. FOK (fill-or-kill) never gets here: it checks first and does nothing unless the whole quantity can fill. A market order discards it.
Building block: price-time priority as a sort key
Section titled “Building block: price-time priority as a sort key”The ranking rule fits in one tuple. For bids, (-price, seq): higher price sorts first, then lower arrival number. For asks, (price, seq). Drop those keys into a min-heap and the top is always the next order to trade against.
import heapqfrom itertools import count
seq = count() # 1. a global arrival counter: "time" in price-time priority
def bid_key(price: int) -> tuple[int, int]: return (-price, next(seq)) # 2. highest price first (negate for a min-heap), then oldest
def ask_key(price: int) -> tuple[int, int]: return (price, next(seq)) # 2. lowest price first, then oldest
if __name__ == "__main__": bids: list[tuple[tuple[int, int], str]] = [] asks: list[tuple[tuple[int, int], str]] = [] for oid, price in [("B1", 99), ("B2", 101), ("B3", 101), ("B4", 100)]: heapq.heappush(bids, (bid_key(price), oid)) for oid, price in [("S1", 103), ("S2", 102), ("S3", 102)]: heapq.heappush(asks, (ask_key(price), oid))
best_bid, best_ask = -bids[0][0][0], asks[0][0][0] # 3. the heap top is the best price assert (best_bid, best_ask) == (101, 102) assert best_ask - best_bid == 1 # spread assert (best_bid + best_ask) / 2 == 101.5 # mid # 4. popping yields the exact order a matcher would visit resting orders assert [heapq.heappop(bids)[1] for _ in range(4)] == ["B2", "B3", "B4", "B1"] assert [heapq.heappop(asks)[1] for _ in range(3)] == ["S2", "S3", "S1"] print("ok")import assert from "node:assert/strict";
type Key = [number, number]; // [primary, arrival seq]; smaller = higher priorityconst less = (a: Key, b: Key) => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
// TS has no built-in heap: a minimal binary min-heap of [key, id].class Heap { private a: [Key, string][] = []; peek() { return this.a[0]; } push(x: [Key, string]) { const a = this.a; a.push(x); for (let i = a.length - 1, p; i > 0 && less(a[i][0], a[(p = (i - 1) >> 1)][0]); i = p) [a[i], a[p]] = [a[p], a[i]]; } pop(): [Key, string] { const a = this.a, top = a[0], last = a.pop()!; if (a.length) { a[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < a.length && less(a[l][0], a[m][0])) m = l; if (r < a.length && less(a[r][0], a[m][0])) m = r; if (m === i) break; [a[i], a[m]] = [a[m], a[i]]; i = m; } } return top; }}
let seq = 0; // 1. a global arrival counter: "time" in price-time priorityconst bidKey = (price: number): Key => [-price, seq++]; // 2. highest price, then oldestconst askKey = (price: number): Key => [price, seq++]; // 2. lowest price, then oldest
const bids = new Heap(), asks = new Heap();for (const [id, p] of [["B1", 99], ["B2", 101], ["B3", 101], ["B4", 100]] as const) bids.push([bidKey(p), id]);for (const [id, p] of [["S1", 103], ["S2", 102], ["S3", 102]] as const) asks.push([askKey(p), id]);const bestBid = -bids.peek()[0][0], bestAsk = asks.peek()[0][0]; // 3. heap top = best priceassert.deepEqual([bestBid, bestAsk, bestAsk - bestBid, (bestBid + bestAsk) / 2], [101, 102, 1, 101.5]);// 4. popping yields the exact order a matcher would visit resting ordersassert.deepEqual([1, 2, 3, 4].map(() => bids.pop()[1]), ["B2", "B3", "B4", "B1"]);assert.deepEqual([1, 2, 3].map(() => asks.pop()[1]), ["S2", "S3", "S1"]);console.log("ok");// PriorityQueue is a min-heap; ValueTuple priorities compare item by item.var bids = new PriorityQueue<string, (long Price, long Seq)>();var asks = new PriorityQueue<string, (long Price, long Seq)>();long seq = 0; // 1. a global arrival counter: "time" in price-time priority
// 2. bids: highest price first (negated), then oldest. asks: lowest price, then oldest.foreach (var (id, p) in new[] { ("B1", 99L), ("B2", 101L), ("B3", 101L), ("B4", 100L) }) bids.Enqueue(id, (-p, seq++));foreach (var (id, p) in new[] { ("S1", 103L), ("S2", 102L), ("S3", 102L) }) asks.Enqueue(id, (p, seq++));
bids.TryPeek(out _, out var bidTop);asks.TryPeek(out _, out var askTop);long bestBid = -bidTop.Price, bestAsk = askTop.Price; // 3. heap top = best priceif (bestBid != 101 || bestAsk != 102) throw new Exception("best prices");if (bestAsk - bestBid != 1) throw new Exception("spread");if ((bestBid + bestAsk) / 2m != 101.5m) throw new Exception("mid");
// 4. dequeuing yields the exact order a matcher would visit resting ordersvar bidOrder = string.Join(",", Enumerable.Range(0, 4).Select(_ => bids.Dequeue()));var askOrder = string.Join(",", Enumerable.Range(0, 3).Select(_ => asks.Dequeue()));if (bidOrder != "B2,B3,B4,B1") throw new Exception(bidOrder);if (askOrder != "S2,S3,S1") throw new Exception(askOrder);Console.WriteLine("ok");A heap is the simplest structure that gives you “best” cheaply. Real engines usually keep one entry per price level (a sorted map, or an array indexed by tick when prices are bounded) with a FIFO queue of orders inside each level, because they also need depth queries, fast cancels and in-place quantity changes. Choosing between those is a design discussion worth having out loud.
Building block: summarizing fills
Section titled “Building block: summarizing fills”After matching, you report what happened. The VWAP (volume-weighted average price) is total notional divided by total quantity. Slippage compares it to the mid at the moment you sent the order, usually in basis points (1 bp = 0.01%).
- Total quantity: sum of fill quantities.
- Notional: sum of price × quantity, in integer minor units.
- VWAP = notional / quantity. Keep it exact (fraction or decimal) or round only at the end.
- Slippage in bps = ±(VWAP − mid) / mid × 10,000, signed so a positive number is a cost.
from fractions import Fraction
def summarize(fills: list[tuple[int, int]], mid: Fraction, side: str) -> tuple[int, int, Fraction, Fraction]: """fills = [(price_cents, qty)]. Returns qty, notional, VWAP, slippage in bps vs mid.""" qty = sum(q for _, q in fills) # 1. total quantity filled notional = sum(p * q for p, q in fills) # 2. money that changed hands, exact ints if qty == 0: raise ValueError("no fills") vwap = Fraction(notional, qty) # 3. volume-weighted average price, exact sign = 1 if side == "buy" else -1 # paying above mid hurts a buyer, slip_bps = sign * (vwap - mid) / mid * 10_000 # 4. selling below mid hurts a seller return qty, notional, vwap, slip_bps
if __name__ == "__main__": # A buy that swept two ask levels: 300 @ 10.02, then 200 @ 10.03. Mid was 10.00. qty, notional, vwap, slip = summarize([(1002, 300), (1003, 200)], Fraction(1000), "buy") assert (qty, notional) == (500, 501_200) assert vwap == Fraction(5012, 5) # 1002.4 cents assert slip == Fraction(24) # 24 bps worse than mid _, _, _, s2 = summarize([(999, 100)], Fraction(1000), "sell") assert s2 == Fraction(10) # sold 1 cent below mid: 10 bps cost print("ok")import assert from "node:assert/strict";
type Fill = [priceCents: number, qty: number];
/** Returns qty, notional, VWAP, and slippage in bps vs mid (positive = cost). */export function summarize(fills: Fill[], mid: number, side: "buy" | "sell") { const qty = fills.reduce((s, [, q]) => s + q, 0); // 1. total quantity filled const notional = fills.reduce((s, [p, q]) => s + p * q, 0); // 2. exact while < 2^53 if (qty === 0) throw new RangeError("no fills"); const vwap = notional / qty; // 3. volume-weighted average price (a float, for display) const sign = side === "buy" ? 1 : -1; // paying above mid hurts a buyer, const slipBps = (sign * (vwap - mid) / mid) * 10_000; // 4. selling below mid hurts a seller return { qty, notional, vwap, slipBps };}
const close = (a: number, b: number) => Math.abs(a - b) < 1e-9;// A buy that swept two ask levels: 300 @ 10.02, then 200 @ 10.03. Mid was 10.00.const r = summarize([[1002, 300], [1003, 200]], 1000, "buy");assert.equal(r.qty, 500);assert.equal(r.notional, 501_200);assert.ok(close(r.vwap, 1002.4));assert.ok(close(r.slipBps, 24)); // 24 bps worse than midassert.ok(close(summarize([[999, 100]], 1000, "sell").slipBps, 10)); // sold below midconsole.log("ok");// A buy that swept two ask levels: 300 @ 10.02, then 200 @ 10.03. Mid was 10.00.var r = Summarize([(1002, 300), (1003, 200)], 1000m, "buy");if (r.Qty != 500 || r.Notional != 501_200) throw new Exception("qty/notional");if (r.Vwap != 1002.4m) throw new Exception($"vwap {r.Vwap}");if (r.SlipBps != 24m) throw new Exception($"slippage {r.SlipBps}"); // 24 bps worse than midif (Summarize([(999, 100)], 1000m, "sell").SlipBps != 10m) throw new Exception("sell slippage");Console.WriteLine("ok");
// fills = (price in cents, qty). Slippage in bps vs mid, positive = cost to us.static (long Qty, long Notional, decimal Vwap, decimal SlipBps) Summarize( (long Price, long Qty)[] fills, decimal mid, string side){ long qty = fills.Sum(f => f.Qty); // 1. total quantity filled long notional = fills.Sum(f => f.Price * f.Qty); // 2. money that changed hands if (qty == 0) throw new ArgumentException("no fills"); decimal vwap = (decimal)notional / qty; // 3. decimal keeps 1002.4 exact int sign = side == "buy" ? 1 : -1; // above mid hurts a buyer, decimal slip = sign * (vwap - mid) / mid * 10_000m; // 4. below mid hurts a seller return (qty, notional, vwap, slip);}What it costs
Section titled “What it costs”Let P be the number of price levels on a side, and k the number of resting orders an incoming order trades against.
| Operation | Typical structure | Cost |
|---|---|---|
| Best bid / best ask | Heap top, or first key of a sorted map | O(1) peek; O(log P) to update |
| Add a resting order | Find or create its level, append to the level’s FIFO | O(log P), or O(1) if the level exists and you index levels by price |
| Match an incoming order | Pop from the best level until done | O(k + levels emptied × log P) |
| Cancel by id | Hash map id → order, plus a removable queue node or a “dead” flag | O(1) average (plus level cleanup) |
| Top-N depth | In-order walk of the sorted levels | O(N + log P) with a sorted map; a heap would need O(P log P) |
With lazy deletion, a cancelled order is flagged and skipped when it reaches the front, so the heap never needs arbitrary removal. That trades memory for simplicity. Heaps and sorted containers cover the structures; note that TS/JS has neither built in, Python has heapq but no sorted map in the stdlib, and .NET has PriorityQueue<TElement,TPriority> and SortedDictionary<TKey,TValue>.
Variations you’ll meet
Section titled “Variations you’ll meet”- Time in force. GTC rests until cancelled, day orders expire at the close, IOC and FOK never rest.
- Modify. Exchanges typically keep queue priority when quantity goes down and send the order to the back when quantity goes up or the price changes. Ask which rule applies.
- Self-trade prevention. An account’s buy shouldn’t trade with its own sell. Options: cancel the resting order, cancel the incoming one, or both.
- Pro-rata matching. Some futures markets split a fill across all orders at the best price in proportion to size, instead of FIFO. Rounding the shares then becomes a money-style remainder problem.
- Tick and lot sizes. Prices must be multiples of a tick, quantities of a lot. Validate on entry.
- Multi-venue books. A consolidated view sums size per price across exchanges. Quotes never trade with each other there, so locked and crossed states can appear.
- Positions and P&L. After fills, accounts track position and cost basis per symbol; realized P&L appears when a position shrinks. Keep it in integer cents.
- Pre-trade risk. Reserve cash for resting buys, cap position size, reject orders that would breach limits, all before the order touches the book.
Pitfalls
Section titled “Pitfalls”- Trading at the incoming price instead of the resting price.
- Forgetting time priority within a price level, or letting a modified order keep priority it shouldn’t.
- Stopping too early. A partial fill of one resting order doesn’t end matching; move to the next order or level.
- Resting market, IOC or FOK orders. They never rest.
- FOK checked by trying. If you match first and “undo” on shortfall, you’ve emitted fills you then retract. Check available size at acceptable prices first.
- Empty side. Best bid of an empty book is “none”, not 0. A spread with one empty side is undefined.
- Floating-point prices. Store integer ticks or cents. 0.1 + 0.2 bugs in a matching engine are real money.
- Leaving zero-quantity orders on the book, or empty price levels in the sorted structure (they break “best price” and depth).
In the interview
Section titled “In the interview”- Ask: trade at which price (resting)? FIFO at a price? What happens to leftovers? Market orders? Can an order trade with the same account? Are ids unique forever? Integer prices?
- Name the structures before coding: “Per side, price levels in a sorted structure; inside a level, a FIFO queue; plus a map from id to order for cancels.” Then state each operation’s cost.
- Say the invariant: “After every call, the book is not crossed: best bid < best ask, or one side is empty.”
- Walk through an example out loud with a partial fill and two orders at the same price. Interviewers want to see the priority rule applied correctly.
- Expect follow-ups: cancel and modify, IOC/FOK, depth queries, multiple symbols, accounts with cash and positions, risk checks, end-of-day marking, and what happens at 1M orders per second (memory layout, avoiding allocations, single-threaded matching per symbol).
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Classic exercises, not gym questions:
- Given a depth snapshot of ask levels, compute the cost and VWAP of buying Q units, and how many levels it sweeps.
- From a stream of trades, compute a rolling 5-minute VWAP per symbol.
- Given the book before and after an event, infer which orders were filled, cancelled or added.
- Best time to buy and sell a stock (one transaction, then unlimited, then with a fee): the classic DP warm-up for trading interviews.
Go deeper
Section titled “Go deeper”- Larry Harris, Trading and Exchanges: Market Microstructure for Practitioners (Oxford, 2003): order types, priority rules, market makers. The standard text.
- Investor.gov: Types of orders: market, limit, stop, and time-in-force in plain language.
- WK Selph, “How to Build a Fast Limit Order Book” (2011): the classic sorted-levels + FIFO + id-map design and its costs.
- Jane Street’s tech blog and talks on OCaml and exchange infrastructure for how real systems keep matching deterministic.