The Limit Order Book: Price-Time Priority
How an exchange keeps waiting buy and sell orders, decides who trades first, and how to build that book in C++ with integer prices and checkable invariants.
This lesson is a free sample of the course.
Who is waiting to trade, and at what price
Before any code, one paragraph that applies to the whole course. Nothing in this course is investment advice or a recommendation to buy, sell or hold anything. Every price, order and data series in it is synthetic or illustrative, generated by the programs you see. The course teaches how trading software is engineered; real trading involves financial risk, legal obligations and regulation that it does not cover, and no program here is evidence that any strategy makes money.
Now the market. Picture one product, say a share or a futures contract, traded on one electronic venue. Some participants are willing to wait. One says "I will buy 5 units, but only at 100.00 or less". Another says "I will sell 4 units, but only at 100.02 or more". Each of these is a limit order: a side (buy or sell), a quantity, and a limit price that the trader will not go beyond. If nobody is on the other side at an acceptable price, the order rests: the venue stores it and waits.
Other participants want to trade now. A trader who sends "buy 3 units at 100.02" while a seller is resting at 100.02 trades immediately against that resting order. The resting order supplied the opportunity to trade; its owner is called the maker (it made liquidity). The incoming order that trades against it is the taker (it took liquidity). Each trade between a taker and one maker is a fill, and an order can be filled in several pieces.
The structure that holds all resting orders is the limit order book. The survey by Gould and coauthors listed under further reading opens by noting that limit order books match buyers and sellers in more than half of the world's financial markets. Every exchange's core starts here, and so does this course. In this lesson we build the book itself: storing orders, cancelling them, answering "what is the best price" and drawing the result. Matching incoming orders against it is the next lesson.
Best bid, best ask and the ladder
A few words do most of the work in this field, so here they are with one example to hang them on.
- A bid is a resting buy order; an ask (also called an offer) is a resting sell order.
- The best bid is the highest price any buyer is willing to pay. The best ask is the lowest price any seller is willing to accept.
- The spread is best ask minus best bid. The mid (mid-price) is their average.
- Depth at a price is the total quantity resting there. A level is one price together with all orders resting at it.
- The tick size is the smallest allowed price step. If the tick is 0.01, a price of 100.015 cannot be entered.
- A lot is the smallest tradable quantity unit. We count quantities in whole lots.
The usual picture of a book is a ladder: one row per price, asks above, bids below, with the gap between them being the spread. The program below builds a book from eight orders and prints it.
Look at the types first. A price is a std::int64_t counting ticks, not a double. With a tick of 0.01, the price 100.02 is stored as 10002. Integers compare exactly, add exactly and make perfect map keys; the pitfall section shows what goes wrong with double, and the lesson on fixed-point money covers the arithmetic in depth. Formatting back to a decimal string happens only when printing, in px.
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <print>
#include <string>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
// Tick size 0.01: 10002 ticks print as "100.02".
std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
template <class Levels>
Qty depth(const Levels& levels, Price price) {
Qty sum = 0;
if (auto it = levels.find(price); it != levels.end())
for (const Order& o : it->second) sum += o.qty;
return sum;
}
int main() {
std::map<Price, std::deque<Order>, std::greater<>> bids; // highest price first
std::map<Price, std::deque<Order>> asks; // lowest price first
const Order orders[] = {
{1, Side::Buy, 10000, 5}, {2, Side::Buy, 9999, 3}, {3, Side::Buy, 10000, 2},
{4, Side::Buy, 9997, 10}, {5, Side::Sell, 10002, 4}, {6, Side::Sell, 10003, 6},
{7, Side::Sell, 10002, 1}, {8, Side::Sell, 10005, 8},
};
for (const Order& o : orders)
(o.side == Side::Buy ? bids[o.price] : asks[o.price]).push_back(o);
std::println("{:>8} {:>6} {:>6}", "price", "bids", "asks");
for (auto it = asks.rbegin(); it != asks.rend(); ++it) // asks on top, highest first
std::println("{:>8} {:>6} {:>6}", px(it->first), "", depth(asks, it->first));
for (const auto& [price, queue] : bids)
std::println("{:>8} {:>6} {:>6}", px(price), depth(bids, price), "");
Price bid = bids.begin()->first, ask = asks.begin()->first;
Price twice_mid = bid + ask; // the mid can fall between two ticks
std::println("best bid {} best ask {} spread {} ticks", px(bid), px(ask),
ask - bid);
std::println("mid {}{}", px(twice_mid / 2), twice_mid % 2 ? "5" : "");
}Press Run to compile and run this example.
price bids asks
100.05 8
100.03 6
100.02 5
100.00 7
99.99 3
99.97 10
best bid 100.00 best ask 100.02 spread 2 ticks
mid 100.01Two details are worth noticing. The two maps are ordered differently on purpose: std::greater<> makes bids.begin() the highest price, so the best price on each side is always at begin(). And the level at 100.00 shows 7, the sum of two orders (5 from order 1, then 2 from order 3). The ladder hides which order is first in line at that price. That order matters a great deal, and it is the subject of the next section.
The mid is printed with integer arithmetic too. If the spread were an odd number of ticks, say 100.00 and 100.03, the mid would be 100.015, which is not a valid price but is a perfectly good statistic. Keeping twice the mid as an integer and appending the final 5 avoids pulling a double into the book.
Price-time priority: who trades first
Suppose a seller arrives wanting to sell 4 lots at any price down to 99.99. Which buyers trade with it? The survey by Gould and coauthors calls price-time priority by far the most common answer. It has two rules, applied in order:
- Better price first. A bid at 100.00 is filled before any bid at 99.99, because the seller gets more for it.
- Earlier time first, at the same price. Among bids at 100.00, the one that arrived first is filled first. Order 1 arrived before order 3, so order 1 is first in line.
That is why each level is a std::deque<Order>: arrivals go on the back, fills come off the front, exactly a first-in, first-out queue. Rule 2 rewards traders for committing early. Showing a price to the whole market exposes you to being traded against when the price moves; time priority gives you something in return, a place in the queue that later arrivals cannot jump. Venues are careful about what keeps and what loses that place. The Xetra market model, for example, says that changing an order's price or increasing its quantity gives it a new time priority, while decreasing the quantity keeps the current one. If increasing kept priority, anyone could hold an early spot with a tiny order and later enlarge it.
Price-time priority is not the only rule. Some products, commonly futures, use pro-rata allocation, which splits an incoming order among all resting orders at the best price in proportion to their sizes. CME Globex documents several algorithms, including FIFO and pro-rata variants; its pro-rata step rounds each order's share down and leaves the leftover lots to later steps, which end in time order. The program below lists the bids in priority order and then compares the two rules for that 4-lot sell at the 100.00 level, using a simplified pro-rata rule (real ones add minimum allocations and further steps).
#include <algorithm>
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <print>
#include <string>
#include <vector>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
int main() {
std::map<Price, std::deque<Order>, std::greater<>> bids;
for (Order o : {Order{1, Side::Buy, 10000, 5}, Order{2, Side::Buy, 9999, 3},
Order{3, Side::Buy, 10000, 2}, Order{4, Side::Buy, 9997, 10}})
bids[o.price].push_back(o); // arrival order = order of this list
std::println("priority order of the bids:");
int rank = 0;
for (const auto& [price, queue] : bids)
for (const Order& o : queue)
std::println(" {}. order {} at {} for {}", ++rank, o.id, px(price), o.qty);
const std::deque<Order>& best = bids.begin()->second;
Qty incoming = 4, level_total = 0;
for (const Order& o : best) level_total += o.qty;
// FIFO: walk the queue from the front.
std::vector<Qty> fifo, pro;
Qty left = incoming;
for (const Order& o : best) {
Qty q = std::min(left, o.qty);
fifo.push_back(q);
left -= q;
}
// Simplified pro-rata: proportional share rounded down, remainder in FIFO order.
left = incoming;
for (const Order& o : best) {
pro.push_back(incoming * o.qty / level_total);
left -= pro.back();
}
for (std::size_t i = 0; i < best.size() && left > 0; ++i) {
Qty q = std::min(left, best[i].qty - pro[i]);
pro[i] += q;
left -= q;
}
std::println("sell {} at {}:", incoming, px(bids.begin()->first));
for (std::size_t i = 0; i < best.size(); ++i)
std::println(" order {} (size {}): FIFO fills {}, pro-rata fills {}",
best[i].id, best[i].qty, fifo[i], pro[i]);
}Press Run to compile and run this example.
priority order of the bids:
1. order 1 at 100.00 for 5
2. order 3 at 100.00 for 2
3. order 2 at 99.99 for 3
4. order 4 at 99.97 for 10
sell 4 at 100.00:
order 1 (size 5): FIFO fills 4, pro-rata fills 3
order 3 (size 2): FIFO fills 0, pro-rata fills 1Order 2 arrived before order 3 but ranks after it: price beats time. Under FIFO, order 3 gets nothing from this sell because order 1 is ahead of it. Under pro-rata it gets a share in proportion to its size (2/7 of 4 lots is 1.14, rounded down to 1), so arriving early matters less and size matters more. Each rule changes how participants behave: under pro-rata, posting a larger order than you actually want to trade becomes a way to win a bigger share, a point the Gould survey makes explicitly. That is why a venue documents its rule precisely for each product. The rest of the course uses price-time priority.
An order book class with add, cancel and depth
Now the same ideas as a class. The book needs to do four things for now:
add(order)puts a resting order at the back of its level's queue.cancel(id)removes an order the trader no longer wants. This is a hot path, not a rare one: the empirical studies summarized in the Gould survey found that the large majority of limit orders end in cancellation rather than in a trade.best_bid()andbest_ask()return the best prices, or nothing if a side is empty.depth(side, price)returns the quantity resting at one price.
Cancel is the interesting one. The trader sends only the order id. Searching every level for it would cost time proportional to the whole book, so the class keeps an index from OrderId to the order's side and price. With that, cancel finds the right level with one hash lookup and one map lookup, then scans that level's queue for the id. The scan is still linear in the queue's length, and the program counts it to make the cost visible.
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <optional>
#include <print>
#include <string>
#include <unordered_map>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
class OrderBook {
public:
bool add(const Order& o) {
if (o.qty <= 0 || where_.contains(o.id)) return false;
if (o.side == Side::Buy) bids_[o.price].push_back(o);
else asks_[o.price].push_back(o);
where_[o.id] = {o.side, o.price};
return true;
}
bool cancel(OrderId id) {
auto it = where_.find(id);
if (it == where_.end()) return false; // unknown, or already gone
auto [side, price] = it->second;
where_.erase(it);
return side == Side::Buy ? remove(bids_, price, id) : remove(asks_, price, id);
}
std::optional<Price> best_bid() const { return best(bids_); }
std::optional<Price> best_ask() const { return best(asks_); }
Qty depth(Side side, Price price) const {
return side == Side::Buy ? total(bids_, price) : total(asks_, price);
}
long scanned = 0; // queue entries inspected by cancel
private:
template <class Levels> static std::optional<Price> best(const Levels& levels) {
if (levels.empty()) return std::nullopt;
return levels.begin()->first;
}
template <class Levels> static Qty total(const Levels& levels, Price price) {
Qty sum = 0;
if (auto it = levels.find(price); it != levels.end())
for (const Order& o : it->second) sum += o.qty;
return sum;
}
template <class Levels> bool remove(Levels& levels, Price price, OrderId id) {
auto level = levels.find(price);
auto& queue = level->second;
for (auto o = queue.begin(); o != queue.end(); ++o) {
++scanned;
if (o->id != id) continue;
queue.erase(o);
if (queue.empty()) levels.erase(level); // never keep an empty level
return true;
}
return false;
}
struct Where { Side side; Price price; };
std::map<Price, std::deque<Order>, std::greater<>> bids_; // best (highest) first
std::map<Price, std::deque<Order>> asks_; // best (lowest) first
std::unordered_map<OrderId, Where> where_; // id -> its level
};
int main() {
OrderBook book;
book.add({1, Side::Buy, 10000, 5});
book.add({3, Side::Buy, 10000, 2});
book.add({5, Side::Sell, 10002, 4});
std::println("best bid {}, depth there {}", px(*book.best_bid()),
book.depth(Side::Buy, 10000));
// Separate statements: argument evaluation order is unspecified.
const bool first = book.cancel(1);
const bool again = book.cancel(1);
std::println("cancel 1: {}, cancel 1 again: {}", first, again);
std::println("depth at 100.00 now {}", book.depth(Side::Buy, 10000));
book.cancel(3);
std::println("bid side empty: {}", !book.best_bid().has_value());
// The cost of cancel: one level holding 1000 orders.
OrderBook big;
for (OrderId id = 1; id <= 1000; ++id) big.add({id, Side::Sell, 10010, 1});
big.scanned = 0;
big.cancel(1);
std::println("cancel the first of 1000: {} entries scanned", big.scanned);
big.scanned = 0;
big.cancel(1000);
std::println("cancel the last of 999: {} entries scanned", big.scanned);
}Press Run to compile and run this example.
best bid 100.00, depth there 7
cancel 1: true, cancel 1 again: false
depth at 100.00 now 2
bid side empty: true
cancel the first of 1000: 1 entries scanned
cancel the last of 999: 999 entries scannedCancelling twice returns false the second time instead of crashing or, worse, removing some other order. That matters in practice: a cancel request can cross a fill on the network, so the engine must treat "that order no longer exists" as an ordinary answer.
Removing the last order at a price removes the level too. That rule, no empty levels, is what lets best_bid() be a one-liner: if empty levels could linger, begin() might point at a price where nobody is waiting.
What each operation costs
Let P be the number of price levels on one side and n the number of orders queued at the level in question.
| Operation | Cost with std::map + std::deque |
|---|---|
add | O(log P) to find or create the level, then amortized O(1) to append |
cancel | O(1) expected for the hash lookup, O(log P) for the level, O(n) to find the order in the queue |
best_bid, best_ask | O(1): begin() of a std::map |
depth | O(log P) plus O(n) to sum, as written |
The last line of the program above demonstrated the O(n) part of cancel: 1 entry scanned for the order at the front, 999 for the order at the back. A single price level can hold many orders, so the scan is worth removing. Two standard fixes are to store, next to each id, an iterator or handle to the order's position (which needs a container whose positions stay valid when others are erased, such as std::list), or to make each order a node in an intrusive doubly linked list, where the order itself holds its previous and next pointers and unlinking is O(1). depth gets the same treatment by caching a running total per level and updating it on every add, cancel and fill. A later lesson, on the latency budget, compares these layouts by counting memory touches; for now the simple version is easier to check, and checking is what the rest of this lesson is about.
Drawing the depth chart
A ladder shows individual levels. A depth chart answers a different question: "how much could I trade if I were willing to go this far from the best price?" For each bid price it plots the total quantity at that price or better (higher), and for each ask price the total at that price or better (lower). The two sides form a valley with its floor at the spread.
The program generates a synthetic book with the course's deterministic random generator Lcg (the standard library's distributions are not guaranteed to produce the same numbers on every platform, so the course uses its own). It prints an SVG drawing: bids in blue on the left, asks in gray on the right, one bar per tick. SVG's y-axis points down, so the program converts quantities to screen coordinates with y().
#include <algorithm>
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <print>
#include <string>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
double uniform() { return (next() + 0.5) / 2147483648.0; } // in (0, 1)
};
std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
constexpr Price kLow = 9985, kHigh = 10017; // price range shown, in ticks
constexpr double kBarWidth = 12, kLeft = 10, kBase = 170, kTop = 20;
double x(Price p) { return kLeft + double(p - kLow) * kBarWidth; }
double y(Qty cumulative, Qty max) { // flip: SVG's y grows downward
return kBase - double(cumulative) / double(max) * (kBase - kTop);
}
int main() {
std::map<Price, std::deque<Order>, std::greater<>> bids;
std::map<Price, std::deque<Order>> asks;
Lcg rng{42};
OrderId id = 0;
for (int i = 0; i < 120; ++i) { // 120 synthetic resting orders
bool buy = rng.range(0, 1) == 0;
Price price = buy ? 10000 - rng.range(0, 14) : 10002 + rng.range(0, 14);
Order o{++id, buy ? Side::Buy : Side::Sell, price, rng.range(1, 9)};
(buy ? bids[price] : asks[price]).push_back(o);
}
auto cumulate = [](const auto& levels) { // best price first
std::map<Price, Qty> out;
Qty running = 0;
for (const auto& [price, queue] : levels) {
for (const Order& o : queue) running += o.qty;
out[price] = running;
}
return out;
};
auto cb = cumulate(bids), ca = cumulate(asks);
Qty max = std::max(cb.begin()->second, ca.rbegin()->second);
std::println(R"(<svg viewBox="0 0 420 200">)");
for (const auto& [price, c] : cb)
std::println(R"(<rect x="{}" y="{}" width="{}" height="{}" fill="#2563eb" )"
R"(fill-opacity="0.35" stroke="#2563eb"/>)",
x(price), y(c, max), kBarWidth, kBase - y(c, max));
for (const auto& [price, c] : ca)
std::println(R"(<rect x="{}" y="{}" width="{}" height="{}" fill="#1f2937" )"
R"(fill-opacity="0.2" stroke="#1f2937"/>)",
x(price), y(c, max), kBarWidth, kBase - y(c, max));
std::println(R"(<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#1f2937"/>)",
kLeft, kBase, x(kHigh), kBase);
auto label = [](double at, const char* anchor, Price p) {
std::println(R"(<text x="{}" y="184" font-size="9" text-anchor="{}">{}</text>)",
at, anchor, px(p));
};
label(x(9986), "start", 9986);
label(x(bids.begin()->first) + kBarWidth, "end", bids.begin()->first); // best bid
label(x(asks.begin()->first), "start", asks.begin()->first); // best ask
label(x(10017), "end", 10016);
std::println(R"(<text x="14" y="14" font-size="10">)"
R"(bids: total at this price or higher</text>)");
std::println(R"(<text x="406" y="14" font-size="10" text-anchor="end">)"
R"(asks: total at this price or lower</text>)");
std::println("</svg>");
}Press Run to compile and run this example.
The floor of the valley is the spread: there are no bars at 100.01, because nobody is resting there. The tallest bar on each side is the whole side's quantity. The slopes say how quickly quantity becomes available as you move away from the best price: a steep wall means a large order could trade close to the best price, a shallow slope means it would have to reach further. Real depth charts are drawn from live data; this one is synthetic and says nothing about any real market.
A crossed book must never exist
Our add stores every order as resting, whatever its price. Try adding a buy order at 100.03 to a book whose best ask is 100.02. The buyer is willing to pay 100.03; a seller is waiting at 100.02. They should trade, right now. If they don't, the book shows a best bid above the best ask, which is called a crossed book. (Best bid equal to best ask is called locked; in a single book that is the same failure, because those two orders should also have traded.)
A crossed book is not a market state, it is a bug. A participant could buy at the ask and immediately sell at the higher bid, the spread is negative, and the mid is meaningless. The program shows the state and lists the pairs of orders that should have traded.
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <print>
#include <string>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
int main() {
std::map<Price, std::deque<Order>, std::greater<>> bids;
std::map<Price, std::deque<Order>> asks;
for (Order o : {Order{1, Side::Buy, 10000, 5}, Order{5, Side::Sell, 10002, 4},
Order{6, Side::Sell, 10003, 6}, Order{9, Side::Buy, 10003, 7}})
(o.side == Side::Buy ? bids[o.price] : asks[o.price]).push_back(o);
Price bid = bids.begin()->first, ask = asks.begin()->first;
std::println("best bid {} best ask {} spread {} ticks", px(bid), px(ask),
ask - bid);
if (bid >= ask) std::println("crossed: these pairs should already have traded");
for (const auto& [bp, bq] : bids)
for (const auto& [ap, aq] : asks)
if (bp >= ap)
for (const Order& b : bq)
for (const Order& a : aq)
std::println(" buy {} at {} vs sell {} at {}",
b.id, px(bp), a.id, px(ap));
}Press Run to compile and run this example.
best bid 100.03 best ask 100.02 spread -1 ticks
crossed: these pairs should already have traded
buy 9 at 100.03 vs sell 5 at 100.02
buy 9 at 100.03 vs sell 6 at 100.03The missing piece is matching: when an order arrives that can trade, it must trade first, and only what is left may rest. Order 9 should have bought 4 lots from order 5 at 100.02 and 3 lots from order 6 at 100.03, and nothing of it would rest. That is exactly what the matching engine in the next lesson does. Until then, a book that only accepts resting orders should refuse an order that would cross, which is what the last program in this lesson does.
It is tempting to store prices the way they look, as 100.03, in a double. The book above uses prices as std::map keys, and double keys break in a way that no test with "nice" numbers will catch.
Most decimal fractions have no exact binary representation. 0.1 is stored as the nearest representable value, which is slightly more than one tenth; the same for 0.2, 0.3 and 0.01. Arithmetic on those approximations does not always land on the approximation of the exact answer. When two computations that should give the same price give different double values, the map treats them as two different prices.
#include <cmath>
#include <cstdint>
#include <map>
#include <print>
int main() {
std::map<double, int> book; // price -> quantity: the mistake
book[0.1 + 0.2] += 5; // a price computed from two others
book[0.3] += 7; // the same price, typed in
std::println("levels: {}", book.size());
for (const auto& [price, qty] : book) std::println(" {:.17f} -> {}", price, qty);
// A price built by adding one tick at a time drifts away from the typed price.
double built = 100.0;
for (int i = 0; i < 3; ++i) built += 0.01;
std::println("100.0 + 3 * 0.01 == 100.03: {}", built == 100.03);
// Integer ticks: convert once, at the edge of the system, and round.
std::int64_t ticks = std::llround(built / 0.01);
std::println("as ticks: {} (== 10003: {})", ticks, ticks == 10003);
}Press Run to compile and run this example.
levels: 2
0.29999999999999999 -> 7
0.30000000000000004 -> 5
100.0 + 3 * 0.01 == 100.03: false
as ticks: 10003 (== 10003: true)The symptom in a real book is quiet and nasty: two levels a hair apart at "the same" price, depth at a price reported as 5 when 12 lots rest there, a cancel that cannot find its order because its price was computed differently from the key it was stored under, and time priority broken between orders that should share a queue. Comparisons with a tolerance do not rescue a std::map: the map needs a strict weak ordering, and "equal within epsilon" is not transitive.
The fix is the one this lesson started with: store prices as integer ticks from the moment they enter the system. If a price arrives as decimal text, parse it straight into ticks; if it arrives as a double, round it to the nearest tick once with std::llround and reject it if it was not close to a tick. After that, all comparisons, keys and sums are exact.
An order book has a short list of properties that must hold after every operation. Write them down as code, in one function, and call it from tests after every step:
- no level is empty, so
begin()is always a real best price; - every order sits at the level whose key equals its price, on the side that matches its
Side, with a positive quantity; - every resting order has exactly one entry in the id index, and every index entry points to a resting order;
- the book is not crossed: best bid is below best ask.
Then drive the book with a long, seeded stream of random operations and check after each one. Random streams try orderings no one would write by hand, and the fixed seed makes every failure reproducible: rerun with the same seed and the same operation fails again. The program below runs 20,000 operations through a correct book and through one with a single injected bug, a cancel that forgets to remove an empty level. Until the next lesson adds matching, add refuses orders that would cross.
#include <cstdint>
#include <deque>
#include <format>
#include <functional>
#include <map>
#include <print>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { return lo + int(next() % std::uint32_t(hi - lo + 1)); }
double uniform() { return (next() + 0.5) / 2147483648.0; } // in (0, 1)
};
std::string px(Price p) { return std::format("{}.{:02}", p / 100, p % 100); }
class OrderBook {
public:
bool forget_empty_levels = false; // the injected bug
bool add(const Order& o) {
if (o.qty <= 0 || where_.contains(o.id)) return false;
if (o.side == Side::Buy && !asks_.empty() && o.price >= asks_.begin()->first)
return false; // would cross: needs matching (lesson 2)
if (o.side == Side::Sell && !bids_.empty() && o.price <= bids_.begin()->first)
return false;
if (o.side == Side::Buy) bids_[o.price].push_back(o);
else asks_[o.price].push_back(o);
where_[o.id] = {o.side, o.price};
return true;
}
bool cancel(OrderId id) {
auto it = where_.find(id);
if (it == where_.end()) return false;
auto [side, price] = it->second;
where_.erase(it);
return side == Side::Buy ? remove(bids_, price, id) : remove(asks_, price, id);
}
// Returns an empty string if every invariant holds, else the first violation.
std::string verify() const {
std::size_t resting = 0;
std::string err = check(bids_, Side::Buy, resting);
if (err.empty()) err = check(asks_, Side::Sell, resting);
if (!err.empty()) return err;
if (resting != where_.size()) return "index size differs from resting orders";
if (!bids_.empty() && !asks_.empty() &&
bids_.begin()->first >= asks_.begin()->first)
return "crossed book";
return "";
}
std::size_t levels() const { return bids_.size() + asks_.size(); }
std::size_t orders() const { return where_.size(); }
private:
template <class Levels> bool remove(Levels& levels, Price price, OrderId id) {
auto level = levels.find(price);
auto& queue = level->second;
for (auto o = queue.begin(); o != queue.end(); ++o) {
if (o->id != id) continue;
queue.erase(o);
if (queue.empty() && !forget_empty_levels) levels.erase(level);
return true;
}
return false;
}
template <class Levels>
std::string check(const Levels& levels, Side side, std::size_t& resting) const {
for (const auto& [price, queue] : levels) {
if (queue.empty()) return "empty level at " + px(price);
for (const Order& o : queue) {
++resting;
auto it = where_.find(o.id);
if (o.price != price || o.side != side || o.qty <= 0 ||
it == where_.end() || it->second.side != side ||
it->second.price != price)
return std::format("order {} is inconsistent", o.id);
}
}
return "";
}
struct Where { Side side; Price price; };
std::map<Price, std::deque<Order>, std::greater<>> bids_;
std::map<Price, std::deque<Order>> asks_;
std::unordered_map<OrderId, Where> where_;
};
void run(OrderBook& book, const char* name) {
Lcg rng{7};
std::vector<OrderId> live; // ids that should be resting
OrderId next_id = 0;
int refused = 0; // orders that would have crossed
for (int step = 1; step <= 20'000; ++step) {
if (live.empty() || rng.range(0, 9) < 6) {
bool buy = rng.range(0, 1) == 0;
Price price = buy ? 10000 + rng.range(-12, 2) : 10000 + rng.range(-2, 12);
Order o{++next_id, buy ? Side::Buy : Side::Sell, price, rng.range(1, 9)};
if (book.add(o)) live.push_back(o.id);
else ++refused;
} else { // cancel a random live order: swap it to the back, then pop
std::swap(live[std::size_t(rng.range(0, int(live.size()) - 1))], live.back());
book.cancel(live.back());
live.pop_back();
}
if (std::string err = book.verify(); !err.empty()) {
std::println("{}: verify failed after operation {}: {}", name, step, err);
return;
}
}
std::println("{}: 20000 operations ({} refused), all checks passed", name, refused);
std::println(" {} orders resting on {} levels", book.orders(), book.levels());
}
int main() {
OrderBook good, buggy;
buggy.forget_empty_levels = true;
run(good, "correct book");
run(buggy, "buggy book");
}Press Run to compile and run this example.
correct book: 20000 operations (1992 refused), all checks passed
2050 orders resting on 25 levels
buggy book: verify failed after operation 7: empty level at 100.07The bug shows up at operation 7, the first time a cancel empties a level. Without verify() it would have surfaced much later and somewhere else: as a "best ask" with nothing behind it, or as a matching loop in the next lesson that reads the front of an empty queue. Checking invariants at every step moves the failure to the operation that caused it.
Two habits make this pay off. Keep verify() in the class, next to the data it checks, so it changes when the representation changes. And make it cheap to call from tests but do not call it on every operation in production code paths: it is O(total orders), while the operations it checks are mostly O(log P).
Try it yourself
- In the first program, add a sell order
{9, Side::Sell, 10001, 2}. Predict the new spread and mid before running it. Then add a buy at 10001 as well and explain the output. - In the price-time priority program, change the incoming sell from 4 lots to 6. Predict both allocations by hand first. Which rule gives order 3 more, and why does that make sense for each rule?
- In the
OrderBookcost demo, cancel order 500 of the 1000. Predict the scan count. Then sketch, without coding it yet, what the index would have to store to make the count 1 for every order. - In the
verify()program, change the injected bug: instead of keeping empty levels, makecancelforget to erase the id from the index (movewhere_.erase(it)behind a flag). Predict which invariant fails first, then run it. Does the failure appear sooner or later than operation 7, and why? - In the depth chart, change the seed of
Lcgand the number of orders. Check that the bar at the best bid always equals the depth at that single price, and that the tallest bar always equals the whole side's quantity.
- Larry Harris, "Trading and Exchanges: Market Microstructure for Practitioners", Oxford University Press, 2003. A thorough, readable account of order types, order-precedence rules and why markets are organized the way they are.
- Martin D. Gould, Mason A. Porter, Stacy Williams, Mark McDonald, Daniel J. Fenn and Sam D. Howison, "Limit Order Books", Quantitative Finance 13(11), 2013. A survey of how limit order books work and what is known about them empirically.
- Deutsche Börse, "T7 Release 10.1: Market Model for the Trading Venue Xetra", 2022. A real venue's rules written for participants: price/time priority, what a modification does to time priority, and many worked matching examples.
- CME Group, "Supported Matching Algorithms", client systems documentation. FIFO, pro-rata and their variants as one venue defines them.
- A limit order book stores resting buy orders (bids) and sell orders (asks). The best bid is the highest bid, the best ask the lowest ask; the spread is the gap between them.
- Price-time priority fills better prices first and, at one price, earlier orders first. A FIFO queue per price level implements it directly; pro-rata is a different rule some products use.
std::mapkeyed by price, withstd::greater<>for bids, puts the best price of each side atbegin(). Astd::deque<Order>per level gives the time order.- Store prices as integer ticks.
doublekeys split one price into several levels and break cancels and priority. cancelneeds an id index to find the level; as written it then scans the level's queue, O(n). A handle or intrusive list removes that scan.- A crossed book (best bid at or above best ask) is a bug: orders that can trade must trade before anything rests.
- Write
verify()with every invariant and run it after each step of a seeded random test; it pins a failure to the operation that caused it.
Quiz — Test Your Knowledge
(50 XP)1. Bids rest at 100.00 (order A, then order B, both 5 lots) and at 99.99 (order C, which arrived before both). A sell order for 6 lots at 99.99 arrives. Under price-time priority, who is filled?
2. Why does the bid side use std::map<Price, std::deque<Order>, std::greater<>>?
3. A book stores prices as double. Orders entered at a typed price of 0.3 and at a computed price of 0.1 + 0.2 end up at two different levels. What is the right fix?
4. With the lesson's OrderBook, cancelling the last of 1000 orders at one price scans 999 or 1000 entries. Which change makes cancel O(1) after the lookup?
5. A resting-only book has best bid 100.03 and best ask 100.02. What does that tell you?