🧠 Order Matching Engine LLD — Thought Process Guide¶
Goal: Reason your way to a correct, deterministic order book in an interview, then defend the data-structure and concurrency choices.
📊 Class Diagram¶
classDiagram
class MatchingEngine {
-dict~str, SymbolSequencer~ _sequencers
+submit(symbol, Command) Future~ExecutionReport~
+limit(symbol, client_id, side, qty, Decimal price, tif) ExecutionReport
+market(symbol, client_id, side, qty, tif) ExecutionReport
+cancel(symbol, order_id) ExecutionReport
+close()
}
class SymbolSequencer {
-Queue _queue
-int _seq
+OrderBook book
+Journal journal
+submit(Command) Future
-_run()
}
class Journal {
+append(JournalEntry)
+entries() list~JournalEntry~
}
class OrderBook {
+Instrument instrument
+BookSide bids
+BookSide asks
-dict~int, Order~ _live
+apply(seq, Command) ExecutionReport
+best_bid() int
+best_ask() int
+snapshot() dict
}
class BookSide {
-dict~int, PriceLevel~ _levels
-list~int~ _heap
+best_level() PriceLevel
+add(Order)
+remove(Order)
+levels() Iterator
}
class PriceLevel {
+int price
+int total_quantity
-OrderedDict~int, Order~ _orders
+append(Order)
+head() Order
+fill_head(qty) Order
+remove(order_id) Order
}
class Order {
+int order_id
+Side side
+OrderType order_type
+TimeInForce tif
+int quantity
+int price
+int remaining
+OrderStatus status
+fill(qty)
}
class Instrument {
+str symbol
+Decimal tick_size
+to_ticks(Decimal) int
+to_price(int) Decimal
}
class NewOrder
class CancelOrder
class ExecutionReport {
+int seq
+OrderStatus status
+int filled_quantity
+tuple events
+trades() list~Trade~
}
class Trade {
+int trade_id
+int price
+int quantity
+int maker_order_id
+int taker_order_id
}
MatchingEngine "1" *-- "many" SymbolSequencer
SymbolSequencer "1" *-- "1" OrderBook
SymbolSequencer "1" *-- "1" Journal
OrderBook "1" *-- "2" BookSide
BookSide "1" *-- "many" PriceLevel
PriceLevel "1" o-- "many" Order
OrderBook --> Instrument
OrderBook ..> NewOrder : applies
OrderBook ..> CancelOrder : applies
OrderBook ..> ExecutionReport : returns
ExecutionReport o-- Trade
⏱️ How to run this in a 45–60 min interview¶
| Time | Step | What to say out loud |
|---|---|---|
| 0–7 min | Clarify | "Limit + market, price-time priority, partial fills, cancel. IOC/FOK as stretch. One book per symbol. In-memory, single process, but I'll design so it can be made durable." |
| 7–15 min | Entities + interfaces | Name Order, PriceLevel, BookSide, OrderBook, Trade. Say the key decision early: "prices are integer ticks, time priority is the sequence number, not a timestamp." |
| 15–40 min | Core code | PriceLevel (FIFO), BookSide (best price), OrderBook._match loop, rest-or-cancel the remainder, cancel by id. Run 3 scenarios by hand: no cross, partial fill across two makers, sweep two levels. |
| 40–50 min | Concurrency | "The book is single-threaded on purpose. One sequencer thread per symbol; clients enqueue. That gives a total order, which gives determinism, which gives replay." |
| 50–60 min | Extension | IOC/FOK (FOK needs a dry-run liquidity check), then whatever they ask: modify, stop orders, self-trade prevention (see Interview Questions). |
If you are behind at minute 35, drop FOK and the sequencer code; say how they work. A correct _match loop beats a half-written engine.
Phase 0: Clarifying questions worth asking¶
- Order types? Limit and market only, or also stop / iceberg / pegged? (Assume limit + market, TIF GTC/IOC/FOK.)
- Matching rule? Price-time (FIFO) is the default for equities. Some futures markets use pro-rata. Confirm FIFO.
- Trade price? The resting (maker) order's price. An aggressive buy at 105 against an ask at 100 trades at 100: price improvement goes to the taker.
- Can market orders rest? No. A market order has no price; unfilled remainder is cancelled. So market + GTC is rejected.
- Price representation? Tick size per instrument. Engine uses integer ticks; the boundary converts from
Decimal. - Multiple symbols? Yes, but symbols are independent: no cross-symbol atomicity needed. That makes per-symbol sharding trivial.
- Durability / recovery in scope? Usually "discuss". Plan for a journal anyway, because it shapes the concurrency model.
- Self-trade prevention, modify/replace? Typical follow-ups; park them.
Phase 1: Identify the nouns¶
"Clients send buy and sell orders for a symbol. Orders that cross the best opposite price trade; the rest wait in the book in price then time order."
| Noun | Decision | Why |
|---|---|---|
| Side, OrderType, TimeInForce, OrderStatus | Enum | Closed sets; makes invalid combinations explicit |
| Instrument | Frozen dataclass | Symbol + tick size; owns Decimal ↔ ticks conversion |
| Order | Mutable dataclass | Identity + remaining changes as it fills |
| PriceLevel | Class | FIFO queue of orders at one price + running total |
| BookSide | Class | Price → level map + best-price index |
| OrderBook | Class | The matching state machine for one symbol |
| NewOrder / CancelOrder | Frozen dataclass | Commands: the input stream we journal and replay |
| Trade, OrderAccepted, OrderRested, OrderCancelled, OrderRejected | Frozen dataclass | Events: the output stream for market data / clearing |
| SymbolSequencer, Journal | Class | Single writer per symbol + write-ahead command log |
Phase 2: Pick the data structures (the real question)¶
What the hot path needs:
| Operation | Frequency | Structure | Cost |
|---|---|---|---|
| Best bid / ask | Every order | Heap of prices (max-heap for bids via negation) | O(1) peek, amortized |
| Add at existing price | Very common | dict[price] → PriceLevel |
O(1) |
| Add at new price | Common | heap push | O(log L) |
| Fill the oldest order at a level | Every trade | OrderedDict head |
O(1) |
| Cancel by order id | Very common (most orders are cancelled, not filled) | dict[order_id] → Order + OrderedDict.pop |
O(1) |
L = number of price levels on one side.
Why OrderedDict and not deque for a level: a deque gives FIFO but cancel-from-the-middle is O(n). OrderedDict is a hash map plus a doubly linked list, i.e. the classic "intrusive linked list + id map" exchanges use.
Why a heap and not a sorted list: inserting a new level in a sorted list is O(L) shifting. A balanced tree (Java TreeMap, C++ std::map) is the textbook answer: O(log L) insert and in-order iteration. Python's stdlib has no balanced tree, so heap + dict + lazy deletion is the idiomatic stdlib choice. In production, if tick range is bounded, an array indexed by tick offset gives O(1) everything (see Interview Questions).
Lazy deletion detail: when a level empties, delete it from the dict right away, leave its heap entry, and pop stale entries when they surface at the top. A _in_heap set stops duplicate heap entries when a price level is emptied and re-created.
Phase 3: Write the match loop first¶
while taker.remaining > 0:
level = contra.best_level()
if level is None or not crosses(taker, level.price):
break
while taker.remaining > 0 and level: # FIFO inside the level
qty = min(taker.remaining, level.head().remaining)
maker = level.fill_head(qty)
taker.fill(qty)
emit Trade(price=level.price, ...) # maker's price
contra.drop_level_if_empty(level)
Then the tail decides what happens to the remainder: LIMIT + GTC rests; IOC and MARKET cancel; FOK never gets here with a remainder because it was pre-checked.
Phase 4: Assign responsibilities¶
| Action | Owner | Why |
|---|---|---|
| Decimal → ticks, reject off-tick / float | Instrument.to_ticks |
Validate at the boundary, keep the core integer-only |
| Business validation (qty > 0, market has no price) | OrderBook._validate |
Deterministic → part of replay; emits OrderRejected |
| Price-time matching | OrderBook._match |
One place owns the invariant "book is never crossed after a command" |
| FIFO + level total | PriceLevel |
Keeps total_quantity correct for depth and FOK |
| Best price | BookSide.best_level |
Hides the heap / lazy-deletion details |
| Ordering, journaling, publishing | SymbolSequencer |
The only writer; turns concurrent callers into one stream |
| Routing by symbol | MatchingEngine |
Facade; symbols match in parallel |
Phase 5: Concurrency — say "single writer" before they ask¶
OrderBookhas no locks and that is the design, not an omission.- Many client threads call
engine.submit(). Each symbol'squeue.Queuelinearizes them. One worker thread per symbol assignsseq, journals the command, applies it, publishes events, then resolves the caller'sFuture. - Time priority =
seq. No wall clock in the book, so replaying the journal gives byte-identical trades. - Symbols are independent, so throughput scales by sharding symbols across threads/cores/machines.
- Listeners run on the sequencer thread and are wrapped in try/except: a broken consumer must not stall matching. In production they read from a ring buffer instead (LMAX Disruptor style).
Phase 6: Quick checklist¶
✅ Integer ticks; Decimal only at the edge; floats refused
✅ Price priority across levels, FIFO within a level, trade at maker price
✅ Partial fills on both taker and maker; level totals stay correct
✅ Market orders and IOC never rest; FOK is all-or-nothing with no side effects
✅ Cancel is O(1) and a cancel of a dead order is a normal reject, not an exception
✅ Book never left crossed after any command
✅ Single writer per symbol; journal + replay reproduces the book exactly