Skip to content

Order books and trading mechanics

intermediateCommon atJane StreetCitadelHudson River TradingTwo SigmaStripeRead firstHeapsSorted containersQueues and dequesPractise in the Sandbox →

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.

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.

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).
Price-time matching
step through
asks (sell orders)
S4
105
400
S2
103
200
S3
102
100
S1
102
300
spread 102 − 101 = 1
B1
101
200
B3
101
150
B2
100
300
bids (buy orders)
incomingbuy 500 @ 103
remaining500
fills–
trading nowpartially filledincoming remainder (B* / S*)not acceptable: stop
Incoming LIMIT buy 500 with limit 103. It looks at the asks, lowest price first; at equal prices, the order that arrived first goes first.
1 / 5
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).
  1. 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.
  2. Among resting orders at the same price, the earliest arrival goes first (FIFO).
  3. Each fill is for min(incoming remaining, resting remaining) units, at the resting order’s price.
  4. Remove resting orders that hit zero. Stop when the incoming order is filled or the next best price isn’t acceptable.
  5. 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.

bid_key / ask_key.py
import heapq
from 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")

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.

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%).

  1. Total quantity: sum of fill quantities.
  2. Notional: sum of price × quantity, in integer minor units.
  3. VWAP = notional / quantity. Keep it exact (fraction or decimal) or round only at the end.
  4. Slippage in bps = ±(VWAP − mid) / mid × 10,000, signed so a positive number is a cost.
summarize.py
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")

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>.

  • 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.
  • 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).
  • 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
pick one
Asks: S1 102x100 (arrived first), S2 101x100, S3 102x100. An incoming buy 250 @ 102 arrives. What are the fills, in order?
Check yourself
pick one
Same book. The buy is FOK for 400 @ 102. What happens?
Check yourself
pick one
Your bids live in a binary heap keyed by (-price, seq). Which query is expensive?

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.