Order Book in C++ for Quantitative Finance | QuantPact

Build an L2 order book in C++ with dry runs, learn L1/L2/L3, and see why quantitative finance firms replace std::map for low-latency trading.

By QuantPact Team · · 19 min read

Order Book in C++ for Quantitative Finance | QuantPact

Order Book in Quantitative Finance: Build an L2 Order Book in C++ (and Learn Why It Isn't Fast Enough)

The question that ends most quant developer interviews

Picture a candidate in the final round of a quant developer interview at a high-frequency trading firm. She has prepared for weeks. Data structures, templates, move semantics, the works.

The interviewer slides a marker across the table and says: "Build me an order book."

She does. About eighty lines of clean C++. Two std::maps, one for bids and one for asks. An AddOrder, a CancelOrder, an UpdateOrder, and a matching loop. She dry runs it on the whiteboard, the trades come out right, and she puts the marker down, relieved.

The interviewer nods slowly and asks one more question.

"Good. Now tell me why we would never run this in production."

The room goes quiet.

This is a story that repeats, in different forms, across the world of quantitative finance. The code is correct. The logic is sound. And yet a serious low-latency trading firm would throw it away on day one. So here is the question I want you to carry with you through this article:

Why would a perfectly correct order book be rejected by every serious low-latency trading firm, and what exactly is wrong with it?

We will answer it, but not yet. First we have to build the thing properly, line by line, and watch it work. Because you cannot see what is wrong with a design until you fully understand what it does.


Why the order book matters in quantitative finance

If you strip away every model, every signal, and every fancy statistical technique in quantitative finance, you are left with one object that everything depends on: the order book.

The order book is the market. When you see "the price" of a stock, you are really looking at the top of an order book. When a market maker quotes, a statistical arbitrage strategy fires, or an execution algorithm slices a large order, each of them is reading from, or writing to, an order book.

For a quant developer, the order book matters for three reasons:

  • It is the source of truth for price. Best bid, best ask, spread, and mid-price all come from it.
  • It is the source of most trading signals. Order imbalance, book depth, queue position, and short-term price pressure are all derived from the shape of the book.
  • It is the hottest data structure in the system. In low latency trading, an order book may be updated millions of times per second, and every update must be fast enough that the strategy reading it is not already out of date.

That last point is why the order book shows up so often in quant developer interviews. It looks simple, but it quietly tests data structures, C++ performance, memory layout, and your understanding of how real markets work.


What is an order book?

An order book is a live list of all the buy and sell orders currently waiting to be executed for one instrument, organized by price.

It has two sides:

  • Bids: orders from people who want to buy, sorted from the highest price down. The highest bid is the best bid.
  • Asks (offers): orders from people who want to sell, sorted from the lowest price up. The lowest ask is the best ask.

The gap between the best bid and the best ask is the spread. As long as the best bid is strictly lower than the best ask, nobody trades, because no buyer and seller agree on a price. The moment a bid reaches or crosses an ask, a trade happens and the book updates.

Here is a tiny example:

Side Price Volume
Ask 102 8
Ask 101 6
Ask 100 5
Spread
Bid 99 10
Bid 98 5
Bid 97 12

Best bid is 99, best ask is 100, spread is 1. If someone places a buy order at 100, it crosses the best ask and trades immediately.


L1, L2, and L3 order books: how much detail do you see?

Market data comes in different levels of detail, and each level makes the order book a different engineering problem.

Level Also called What it contains Typical use
L1 Top of book Only the best bid and best ask, with their sizes (plus last trade) Retail apps, simple signals, basic quoting
L2 Market by price (MBP) Total volume at every price level, on both sides Depth analysis, most mid-frequency quant strategies
L3 Market by order (MBO) Every individual order, with its own ID, size, and queue position Market making, queue-position strategies, HFT

Think of it this way. L1 tells you the best price. L2 tells you how many shares are waiting at each price, but lumped together. L3 tells you exactly who is standing in which line, and in what order.

The implementation in this article is an L2 order book: it stores one aggregated volume number per price level. That is the simplest realistic book, which makes it the perfect place to learn. We will point out, as we go, exactly what an L3 book would need on top of it.


Where the order book sits (and why HFT changes the rules)

There are actually two order books in the world, and quants need to know the difference.

  1. The exchange's book. Inside the exchange, the matching engine holds the authoritative order book and decides who trades with whom.
  2. Your firm's local copy. Trading firms rebuild the book on their own servers from the exchange's market data feed, so their strategies can read it in nanoseconds instead of asking the exchange.

A typical low latency trading path looks like this:

  1. The exchange publishes market data messages (add, cancel, modify, trade) over a fast feed.
  2. A feed handler decodes the raw bytes.
  3. The order book builder applies each message to the local book.
  4. The strategy reads the book and decides whether to trade.
  5. The order management system validates risk and sends the order back to the exchange.

The order book sits right in the middle of that path, which is why its performance is so important. In most software, an extra microsecond is invisible. In high-frequency trading, the time from receiving a market data packet to sending an order (often called tick-to-trade) is measured in microseconds, and the order book sits on that critical path.

That shapes the constraints in a way that ordinary software never faces:

  • Latency is deterministic or it is nothing. An average of 2 microseconds with a 500-microsecond spike once in a while is worse than a steady 5.
  • No surprises from the memory allocator. Calling new or malloc on the hot path is usually forbidden.
  • Cache behavior is performance. Where your data sits in memory often matters more than how clever your algorithm is.
  • Bursts are real. At market open, or around news, message rates can jump by orders of magnitude. The book must keep up without falling behind.
  • Correctness under chaos. Feeds drop packets and repeat messages. The book must recover without corrupting itself.

Hold on to those five constraints. They are the key to the question we asked at the start.


The market messages every order book must support

An order book is not a static table. It is a state machine driven by a stream of messages from the market. Almost every feed, regardless of the exchange, boils down to a handful of message types:

Message Meaning Effect on an L2 book Our function
Add New liquidity arrives at a price Increase volume at that level (or create it) AddOrder
Cancel / Delete Someone withdraws liquidity Decrease volume at a level, or remove it CancelOrder
Modify / Update Liquidity moves to a new price or size Remove old level, apply the new one UpdateOrder
Trade / Execute Buyer and seller matched Volume consumed from the best levels MatchingOrder

In a real exchange feed, the trade message arrives from the exchange, because the exchange's matching engine decided it. In our simplified book, the same logic runs inside the book, which also makes it a great model of what the exchange's matching engine does.


Setting up the book: two sorted maps

We will build the order book one function at a time, and follow every function with a dry run so you can watch it work. First, the skeleton: the types, the side enum, and the two maps that hold every price level.

class OrderBook
{
public:
    using Price = int;
    using Volume = int;

    enum class Side { BID, ASK };

private:
    std::map<Price, Volume, std::greater<Price>> bids_;  // highest price first
    std::map<Price, Volume> asks_;                        // lowest price first

public:
    // AddOrder, MatchingOrder, CancelOrder and UpdateOrder come next.
};

Before we walk through it, notice the one clever trick. The bids map uses std::greater<Price>, so bids_.begin() is always the highest bid. The asks map uses the default ordering, so asks_.begin() is always the lowest ask. That means the best bid and best ask are always the first element of each map, and matching only ever needs to look at begin().


AddOrder: adding liquidity to the book

AddOrder takes a price, a side, and a volume. It adds that volume to the matching price level (creating the level if it does not exist), then calls MatchingOrder() in case the new order crossed the spread.

    // Add volume to a price level.
    void AddOrder(Price price, Side side, Volume volume)
    {
        if (volume <= 0) return;

        if (side == Side::BID) bids_[price] += volume;
        else                   asks_[price] += volume;

        MatchingOrder();
    }

The bids_[price] += volume line does a lot of work in one expression. If the level exists, it adds to it. If it does not, operator[] creates it with a default of zero and then adds. Inserting into a std::map costs O(log n), where n is the number of price levels. If matching then runs, it adds work proportional to the number of levels consumed, which is why the full cost is O(log n + k), where k is the number of price levels that get matched.

Dry run 1: building a bid side

Start with an empty book.

AddOrder(110, BID, 10)
Bids: [(110, 10)]

AddOrder(100, BID, 10)
Bids: [(110, 10), (100, 10)]

AddOrder(109, BID, 10)
Bids: [(110, 10), (109, 10), (100, 10)]

Notice that the third order, at 109, was slotted into the middle automatically. The map keeps the bids sorted from highest to lowest, so we never sort anything by hand. No asks exist yet, so no matching happens.

But AddOrder has a second job. If the new order crosses the spread, it must trade. That is the work of MatchingOrder(), the last line of the function. Let's look at that function first, and then come back to watch an add that actually causes a trade.


MatchingOrder: one order, many trades

The matching loop is the heart of the book. The key line is while rather than if: a single incoming order can sweep through several price levels.

    // Match while the best bid >= the best ask.
    void MatchingOrder()
    {
        while (!bids_.empty() && !asks_.empty())
        {
            auto bid = bids_.begin();
            auto ask = asks_.begin();

            if (bid->first < ask->first) break;   // no crossing left

            const Volume trade_volume = std::min(bid->second, ask->second);

            bid->second -= trade_volume;
            ask->second -= trade_volume;

            if (bid->second == 0) bids_.erase(bid);
            if (ask->second == 0) asks_.erase(ask);
        }
    }

Read it from top to bottom. Grab the best bid and the best ask. If the bid is lower than the ask, nobody agrees on a price, so stop. Otherwise, trade the smaller of the two volumes, subtract it from both sides, erase any level that hits zero, and go around again. Now let's watch it run, first with a single trade and then with a sweep through several levels.

Dry run 2: an add that triggers a trade

Now a more interesting case. This is the current state of the book:

Bids: [(99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (109, 6), (110, 7)]

Best bid is 99, best ask is 100. Spread of 1, no trade. Now a buyer arrives:

AddOrder(100, BID, 10)

Step 1: the volume is added to the bids.

Bids: [(100, 10), (99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (109, 6), (110, 7)]

Step 2: MatchingOrder() runs. Best bid (100) is greater than or equal to best ask (100), so the book is crossed and a trade must happen.

trade_volume = min(10, 5) = 5
Bids: [(100, 5), (99, 10), (98, 5), (97, 12)]
Asks: [(109, 6), (110, 7)]      // the ask at 100 hit zero and was erased

Trade: 5 shares at 100.

Step 3: the loop checks again. Best bid is 100, best ask is now 109. The bid is lower, so it breaks. The final book:

Bids: [(100, 5), (99, 10), (98, 5), (97, 12)]
Asks: [(109, 6), (110, 7)]

The buyer wanted 10, but only 5 were available at 100. So 5 traded and the other 5 now rest in the book as a bid at 100. That is how a real limit order behaves.

Dry run 3: one bid fills multiple asks

Starting state:

Bids: [(99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

A large aggressive buyer arrives:

AddOrder(102, BID, 15)

After the add, before matching:

Bids: [(102, 15), (99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

Now the loop runs. Each pass compares the best bid (102) with the best ask.

Pass 1: best ask is 100. Since 102 >= 100, trade min(15, 5) = 5.

Bids: [(102, 10), (99, 10), (98, 5), (97, 12)]
Asks: [(101, 6), (102, 8)]
Trade: 5 @ 100

Pass 2: best ask is 101. Since 102 >= 101, trade min(10, 6) = 6.

Bids: [(102, 4), (99, 10), (98, 5), (97, 12)]
Asks: [(102, 8)]
Trade: 6 @ 101

Pass 3: best ask is 102. Since 102 >= 102, trade min(4, 8) = 4.

Bids: [(99, 10), (98, 5), (97, 12)]
Asks: [(102, 4)]
Trade: 4 @ 102

Pass 4: the bid side's best is now 99, below the best ask of 102, so the loop breaks.

One incoming order produced three trades at three different prices (100, 101, and 102), consuming the cheapest sellers first. This is exactly how price priority works: the best-priced liquidity always trades first. Notice also that each trade happens at the resting order's price, not the aggressor's limit. The buyer was willing to pay up to 102 but got 5 of the shares at 100, which is a better deal.


CancelOrder: removing liquidity

Markets are full of people changing their minds. In fact, in most modern markets the majority of orders are cancelled rather than filled, so CancelOrder is called constantly.

The function finds the price level and then does one of two things. If the cancelled volume is greater than or equal to the volume at that level, the whole level disappears. Otherwise, it just subtracts.

    // Cancel volume from a price level.
    void CancelOrder(Price price, Side side, Volume volume)
    {
        if (volume <= 0) return;

        if (side == Side::BID)
        {
            auto it = bids_.find(price);
            if (it == bids_.end()) return;

            if (volume >= it->second) bids_.erase(it);
            else                      it->second -= volume;
        }
        else
        {
            auto it = asks_.find(price);
            if (it == asks_.end()) return;

            if (volume >= it->second) asks_.erase(it);
            else                      it->second -= volume;
        }
    }

Dry run 4: a partial cancel

Bids: [(99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

CancelOrder(99, BID, 4)

Bids: [(99, 6), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

The level at 99 went from 10 to 6. Nothing else changed.

Dry run 5: a cancel that removes the whole level

CancelOrder(99, BID, 10)

Bids: [(98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

Three small details are worth noticing:

  • Cancel never calls MatchingOrder(). Removing liquidity can only widen the spread or leave it alone. It can never make a bid cross an ask, so there is nothing to match.
  • Cancelling an unknown level silently returns. That is fine for a toy, but in a real feed a cancel for something that does not exist is a loud signal that you have missed a message and your book has drifted from the exchange.
  • Cost is O(log n) for the find, plus an amortized O(1) erase.

UpdateOrder: moving liquidity

When a participant modifies an order, say by changing its price, the book must remove the liquidity from the old price and place it at the new one. In our L2 model, UpdateOrder does this by erasing the old price level entirely and then adding new_volume at new_price. If new_volume is zero, the level is simply deleted and nothing is added.

Because the new price might cross the spread, UpdateOrder ends with a call to MatchingOrder(), just like AddOrder.

    // Remove a price level, then add the new price and volume.
    void UpdateOrder(Price old_price, Price new_price,
                     Side side, Volume new_volume)
    {
        if (new_volume < 0) return;

        if (side == Side::BID)
        {
            auto it = bids_.find(old_price);
            if (it == bids_.end()) return;

            bids_.erase(it);
            if (new_volume > 0) bids_[new_price] += new_volume;
        }
        else
        {
            auto it = asks_.find(old_price);
            if (it == asks_.end()) return;

            asks_.erase(it);
            if (new_volume > 0) asks_[new_price] += new_volume;
        }

        MatchingOrder();
    }

Dry run 6: an update with no matching

Bids: [(99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

UpdateOrder(99, 98, BID, 15)

First, the old level at 99 is removed:

Bids: [(98, 5), (97, 12)]

Then 15 is added at 98. Since a level at 98 already exists with volume 5, the volumes combine to 20:

Bids: [(98, 20), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

Best bid is 98, best ask is 100. No crossing, so no trades.

Dry run 7: an update that triggers matching

Bids: [(99, 10), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

UpdateOrder(99, 101, BID, 12)

Remove the old bid level at 99, then add 12 at 101:

Bids: [(101, 12), (98, 5), (97, 12)]
Asks: [(100, 5), (101, 6), (102, 8)]

Now the best bid (101) is at or above the best ask (100), so MatchingOrder() runs:

Pass 1: trade min(12, 5) = 5 @ 100
Bids: [(101, 7), (98, 5), (97, 12)]
Asks: [(101, 6), (102, 8)]

Pass 2: trade min(7, 6) = 6 @ 101
Bids: [(101, 1), (98, 5), (97, 12)]
Asks: [(102, 8)]

Pass 3: best bid 101 < best ask 102, stop.

Final book:

Bids: [(101, 1), (98, 5), (97, 12)]
Asks: [(102, 8)]

A single update turned into two trades and left one lonely share resting at 101. This is the same behavior as AddOrder, which tells you something useful: the matching logic is the same no matter how the crossing was created, so it lives in one function.

The complexity at a glance

Operation Cost Why
Read best bid / best ask O(1) It is always begin() of each map
AddOrder O(log n + k) Map insert, plus k levels matched
CancelOrder O(log n) One find, one erase or subtract
UpdateOrder O(log n + k) Find, erase, insert, plus k levels matched

In a whiteboard interview, these numbers get a nod. In a low latency trading firm, they are only the beginning of the conversation.


Back to the question: why is this book rejected in production?

Now we can finally answer the question from the start of the article. Remember the five constraints of high-frequency trading from earlier? This clean, correct, textbook order book breaks almost all of them.

1. Every price level is a heap allocation. Each element in a std::map is a separate node, allocated with new behind the scenes. Every time a new price level appears, the allocator runs. Every time a level is erased, memory is freed. That puts the memory allocator, a system with unpredictable timing, on the hottest path in the building.

2. A tree is pointer chasing. Finding a price in a red-black tree means following a chain of pointers from node to node. The nodes were allocated at different times, so they live in scattered places in memory. Each hop can be a cache miss. A hit in the CPU's L1 cache costs a handful of cycles; going out to main memory can cost hundreds. Your O(log n) is not really "log n steps." It is "log n potential trips to RAM."

3. The data you want is always at the top. In real markets, almost all of the activity happens within a few ticks of the best price. Yet the map treats every price level the same and charges you logarithmic time to find them. The access pattern is screaming for something much simpler than a tree.

4. Levels constantly appear and disappear near the touch. Prices near the best bid and ask flicker in and out of existence all day. With a std::map, each flicker is an allocation or a free. The churn itself becomes a source of latency spikes.

5. It is not deterministic. Tree rebalancing, allocator behavior, and cache state all vary from call to call. A strategy needs the tail of the latency distribution to be tight, not just the average.

That is the answer the interviewer was waiting for. The book is not rejected because it is wrong. It is rejected because, in low latency trading, correct is the entry fee, and predictable speed is the product. The candidate who can explain why std::map hurts, and what to do instead, is the one who gets the offer.

The code is also missing things that real quantitative finance systems need. Here is what a senior engineer would flag:

  • No order IDs. An L2 book cannot cancel a specific order, only reduce a price level. Real feeds cancel and modify by order ID.
  • No time priority. At a given price, the first order in line should trade first. An aggregated volume number throws away the queue entirely, which is exactly what market-making strategies care about.
  • UpdateOrder wipes the whole level. That is fine as a model of an L2 feed, but wrong if the message means "change one order."
  • No trade events. The book changes state but never reports what traded, at what price, or who was the aggressor. A real book emits a trade message for every match.
  • No sequence numbers or gap handling. Real market data arrives with sequence numbers. Miss one, and the book must detect it and rebuild from a snapshot.

What high-frequency trading does differently

So what does a production order book look like in a low latency trading system?

Here is the honest answer: it looks almost nothing like the code we just walked through, and yet it behaves exactly the same. Feed it our seven dry runs and it returns the same books and the same trades, down to the last share.

Everything that changes lives underneath. How prices are stored. Where memory comes from. How an order is found in the first place. What happens when the feed drops a packet. Which thread is even allowed to touch the book. Each of those choices quietly removes one of the five problems we just uncovered, and each one comes with a trade-off that trading desks genuinely argue about.

Here is the part that surprises most people: the ideas are simple. Most of them can be explained in a single sentence. The difficulty is applying all of them at once without breaking correctness, and a few of them look flat-out wrong until you see why they work.

Which leaves you with some questions worth sitting with:

  • What do you use when even O(log n) is too slow?
  • How do you cancel an order you cannot afford to search for?
  • Why would a firm deliberately reserve memory it may never use?
  • What should a book do the moment it realizes it has missed a message?
  • Why would you restrict the most important data structure in the building to a single thread?

These are exactly the questions that come after "build me an order book" in a quant developer interview. And each one has an answer that is more interesting than you would expect.


Want to build the real thing?

What you just read is the starting point. It is the version that gets you into the conversation. The conversation that decides the offer begins right after it: "Now make it faster. Show me the memory layout. What happens on a cache miss? How do you handle a dropped packet?"

In our cohort, we take this exact kind of naive L2 book and evolve it step by step into a low-latency design, the way real quantitative finance teams do. We work through the design decisions, the memory layout, the L3 order-level book, and the trade-offs that interviewers probe for in quant developer interviews.

If you want to go from "I can write an order book" to "I can explain and defend how it should be built for production," this is the next step.

Check out the cohort and enroll here →


Frequently asked questions

What is an order book in quantitative finance?

An order book is a real-time list of all buy orders (bids) and sell orders (asks) for an instrument, organized by price. In quantitative finance, it is the source of price, liquidity, and many trading signals, such as order imbalance and depth.

What is the difference between L1, L2, and L3 order books?

L1 shows only the best bid and best ask with their sizes. L2 (market by price) shows the total volume at every price level. L3 (market by order) shows every individual order with its ID and queue position.

Why is std::map too slow for an HFT order book?

std::map allocates a heap node for every price level and stores them in a tree, so lookups chase pointers across memory and can cause cache misses. In low latency trading, allocations and cache misses create unpredictable latency spikes, so firms use contiguous arrays and pre-allocated pools instead.

How does an order book match orders?

When the best bid is greater than or equal to the best ask, the book trades the smaller of the two volumes at the resting order's price, removes any level that reaches zero, and repeats until the best bid is below the best ask.

Do I need to know order books for a quant developer interview?

Yes. Designing and implementing an order book, and then optimizing it for low latency, is a common question in quant developer and HFT interviews because it tests data structures, C++ performance, and market knowledge together.


Key takeaways

  • An order book holds all resting buy and sell orders for an instrument and is the foundation of price in quantitative finance.
  • L1 is the top of book, L2 is volume per price level, and L3 is every individual order with its queue position.
  • An order book is a state machine driven by add, cancel, modify, and trade messages, and matching happens whenever the best bid reaches the best ask.
  • A std::map book is correct but unsuited to low latency trading because of heap allocation, pointer chasing, cache misses, and non-deterministic timing.
  • Production books use contiguous price arrays, memory pools, per-order queues, and sequence-aware recovery.