Davina Titus
Write-up · C++17 · Markets

Building a limit order book

What actually happens when you press "buy," and how I built the part of an exchange that decides.

Code ↗Live demo ↗~3.3M orders/sec17 tests

01The question

Every order on an exchange lands in a limit order book: a list of everyone willing to buy (bids) and sell (asks), sorted by price. When a new order comes in, a matching engine decides who trades with whom, at what price, and in what order. It has to do that correctly every time, and fast.

I wanted to understand that engine well enough to build one, so I wrote a single-instrument order book in C++17 with the order types real exchanges use: limit, market and immediate-or-cancel (IOC), plus cancels and quantity reductions.

02The rules

Matching follows price-time priority, the standard on most exchanges:

The order types differ only in what happens to quantity that doesn't fill: a limit order rests on the book, a market order trades at any price and drops the rest, and an IOC order trades up to its limit and cancels the rest.

03Choosing the data structures

This was the part I spent the most time on, because each operation has different needs.

bids · std::map (highest first) asks · std::map (lowest first) 100.00 99.75 #5 #9 #4 100.50 101.00 #2 #3 #1 FIFO std::list per price level → unordered_map<OrderId, iterator> → jump straight to any order
Two sorted maps of price levels, a queue per level, and an index from order id to its exact position.

04The matching loop

Every order type runs through one loop. It keeps taking the best opposite level while prices cross, and fills orders at that level front to back:

while (incoming.qty > 0 && !book.empty()) {
    auto level = book.begin();               // best opposite price
    if (limit && !crosses(level->first, *limit)) break;

    while (incoming.qty > 0 && !level->second.orders.empty()) {
        Order& resting = level->second.orders.front();
        Qty fill = std::min(incoming.qty, resting.qty);
        trades.push_back({..., level->first, fill});   // resting price
        incoming.qty -= fill;  resting.qty -= fill;
        if (resting.qty == 0) { index_.erase(resting.id); level->second.orders.pop_front(); }
    }
    if (level->second.orders.empty()) book.erase(level);
}

Market orders pass no limit, so they cross at any price. Limit orders rest whatever is left afterward; market and IOC orders simply drop it.

05Testing it

A matching engine that's fast but occasionally wrong is useless, so I wrote 17 unit tests before trusting any benchmark. They cover full and partial fills, price priority, FIFO order within a level, orders that sweep several levels, market and IOC behavior, cancels at the front and middle of a queue, reductions keeping queue position, and bad input like duplicate ids or zero quantities.

The suite also runs under AddressSanitizer and UndefinedBehaviorSanitizer, which catch memory bugs like using an iterator after its order was erased. GitHub Actions builds and tests everything on every push.

06How fast it is

The benchmark replays 1,000,000 synthetic orders with a fixed seed: about 60% limit orders around a drifting price, 30% cancels and 10% market or IOC orders. Only the order book calls are timed.

MetricResult
Throughput~3.3M orders/sec
Median latency~170 ns
99th percentile~0.9 µs
99.9th percentile~4.5 µs
Trades generated~450,000

Measured on a shared cloud Linux machine, so exact numbers vary. The tail is the interesting part: the median is fast, but the slowest 0.1% of orders take over 25 times longer. Most of that comes from memory allocation, since every new order and every new price level allocates a node on the heap, plus noise from the shared machine.

07What I'd do next

Getting it correct was the first half. Making the slow cases fast is the second.