Build a Low-Latency Order Book in C++
Build a benchmarked matching engine with cache-aligned, zero-allocation data structures.
Introduction
30 Second Summary
Every time you buy a stock, your order travels to a matching engine that pairs buyers with sellers in nanoseconds. These systems are the backbone of every financial exchange, from NASDAQ to the CME.
In this project, you will build a high-performance limit order book and matching engine in C++20 that processes orders using price-time priority, the same fundamental algorithm used by every major exchange. You will benchmark it with nanosecond precision to measure per-operation latency.
What You'll Build
A C++20 matching engine that processes limit orders, market orders, and cancellations with sub-microsecond latency using cache-aligned data structures, a pre-allocated memory pool, and integer fixed-point prices.
By the end of this project, you'll have:
- A complete order book with price-time priority matching, the core algorithm of every electronic exchange.
- Cache-line-aligned data structures and a pre-allocated memory pool that eliminates heap allocation on the hot path.
- A nanosecond-precision benchmark harness producing latency percentile histograms (min, p50, p99, p99.9, max) and throughput measurements.
- Secret Mission: Add a lock-free SPSC ring buffer to separate order ingestion from matching, simulating the producer-consumer architecture of real exchange systems.
Are there any prerequisites?
This project assumes strong C++ proficiency (modern C++17/20 features like designated initializers, concepts, and templates). You should be comfortable with memory layout, cache hierarchies, and the CMake build system.
You will also need WSL2 (Windows Subsystem for Linux) with g++ and CMake already installed.
Before We Start
Before We Start...
Set Up the Project
Before writing any matching logic, you need a solid project foundation. A well-structured CMake project with the right compiler flags ensures your code compiles with full C++20 support and maximum optimization for benchmarking.
In this step, you will verify your compiler supports C++20, create the project directory structure, write the build configuration, and confirm everything compiles cleanly.
In this step, get ready to:
- Verify g++ supports C++20 features.
- Create the project directory and CMakeLists.txt build file.
- Create source files and confirm the project builds.
Verify g++ C++20 support
The project uses C++20 features like designated initializers and alignas. GCC 10 or higher is required because that is the first version to support the -std=c++20 flag.
- Check your g++ version by running:
g++ --version
- Confirm the version number shown is 10 or higher (e.g. 10.x, 11.x, 12.x).
✔️ I see version 10 or higher
Your compiler supports C++20. Let's confirm it works end-to-end with a quick compile test.
ⓧ I see a version lower than 10
You need GCC 10+ for C++20 support. Upgrade by running:
sudo apt update && sudo apt install g++-12 -y
- After installing, run g++-12 --version to confirm the new version.
- Set it as default by running:
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100
Run g++ --version again to confirm version 12 is active.
- Create a quick test file using a C++20 feature (designated initializers) by running:
cat > /tmp/test_cpp20.cpp << 'EOF'
#include <cstdio>
struct Point { int x; int y; };
int main() {
Point p{.x = 1, .y = 2};
std::printf("C++20 works: %d, %d\n", p.x, p.y);
return 0;
}
EOF
- Compile and run the test by running:
g++ -std=c++20 -o /tmp/test_cpp20 /tmp/test_cpp20.cpp && /tmp/test_cpp20
You should see C++20 works: 1, 2 printed in your terminal.
Seeing a compilation error?
If you see unrecognized command-line option '-std=c++20', your g++ version is too old. Follow the upgrade instructions in the tab above.
help me fix a C++20 compilation error in WSL
- Clean up the test files by running:
rm /tmp/test_cpp20 /tmp/test_cpp20.cpp
Create the project structure and CMakeLists.txt
Every C++ project needs a clear directory layout. Header files go in include/ and implementation files go in src/. CMake ties it all together with a build configuration.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project directory with its subdirectories and move into it by running:
mkdir -p hft_orderbook/src hft_orderbook/include && cd hft_orderbook
- Confirm the directories exist by running:
ls
You should see include and src listed in the output.
- Create the CMakeLists.txt build file by running:
cat > CMakeLists.txt << 'EOF'
cmake_minimum_required(VERSION 3.16)
project(hft_orderbook LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_BUILD_TYPE Release)
add_executable(hft_orderbook
src/main.cpp
)
target_include_directories(hft_orderbook PRIVATE include)
target_compile_options(hft_orderbook PRIVATE -Wall -Wextra -pedantic -O2)
target_link_options(hft_orderbook PRIVATE -pthread)
EOF
What does this CMake configuration do?
- CMAKE_CXX_STANDARD 20 tells CMake to pass -std=c++20 to the compiler. CMAKE_CXX_EXTENSIONS OFF prevents GNU-specific extensions so the code stays portable.
- CMAKE_BUILD_TYPE Release enables optimizations. Combined with -O2, this ensures the benchmark measures optimized machine code, not debug builds.
- -Wall -Wextra -pedantic enables strict warnings that catch bugs early. HFT codebases treat warnings as errors.
- -pthread links the POSIX threads library, which is needed for the Secret Mission's multi-threaded pipeline.
Create the source files and verify the build
The project needs four header files (one per module) and a single main.cpp entry point. You will fill these in over the next steps. For now, placeholders let the build system compile without errors.
- Create the placeholder header files by running:
echo '// placeholder' > include/types.hpp
echo '// placeholder' > include/memory_pool.hpp
echo '// placeholder' > include/order_book.hpp
echo '// placeholder' > include/benchmark.hpp
- Create the src/main.cpp entry point by running:
cat > src/main.cpp << 'EOF'
#include <cstdio>
int main() {
std::printf("HFT Order Book\n");
return 0;
}
EOF
Why start with a minimal main?
A minimal main.cpp that compiles and runs proves the entire toolchain works end-to-end. If something breaks after adding code later, you know the issue is in your new code, not the build system.
- Build the project by running:
cmake -S . -B build && cmake --build build
You should see CMake configure the project and then compile main.cpp without errors. The last line of output shows the linking step.
- Run the compiled binary to confirm it works:
./build/hft_orderbook
You should see HFT Order Book printed in your terminal.
Build failing?
If CMake says Could not find compiler, make sure you ran the commands inside your WSL terminal, not PowerShell or Command Prompt.
If you see No such file or directory for src/main.cpp, confirm you are inside the hft_orderbook directory by running pwd.
help me debug a CMake build failure in WSL
✔️ Awesome, I've got everything!
Great. Confirm you have saved all files by running:
find . -type f | sort
You should see CMakeLists.txt, the four header files in include/, and src/main.cpp listed.
ⓧ I'd like to double check the full code
Here is every file you should have at this point.
cmake_minimum_required(VERSION 3.16)
project(hft_orderbook LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_BUILD_TYPE Release)
add_executable(hft_orderbook
src/main.cpp
)
target_include_directories(hft_orderbook PRIVATE include)
target_compile_options(hft_orderbook PRIVATE -Wall -Wextra -pedantic -O2)
target_link_options(hft_orderbook PRIVATE -pthread)
#include <cstdio>
int main() {
std::printf("HFT Order Book\n");
return 0;
}
Each header file (include/types.hpp, include/memory_pool.hpp, include/order_book.hpp, include/benchmark.hpp) contains only:
// placeholder
Your project skeleton is ready. Next up, you will define the core data structures that model how a real exchange's order book works.
Define Core Data Structures
Your project skeleton compiles, but it does nothing yet. The first real building block of any exchange system is its order. Every buy, every sell, every cancellation flows through the system as an order object.
In this step, you will define the data structures that represent orders, group them by price, and organize them into the two sides of the order book. You will also learn a key principle of high-frequency systems: aligning data to CPU cache lines so the processor never stalls waiting for memory.
In this step, get ready to:
- Define the Order struct with cache-line alignment and fixed-point pricing.
- Build a PriceLevel struct using an intrusive doubly-linked list for FIFO time priority.
- Declare the OrderBook class with sorted bid and ask sides.
Define the Order struct and market types
Every electronic exchange represents prices as integers, not floating-point numbers. A price of $150.25 becomes the integer 15025 (cents, or "ticks"). This avoids rounding errors that accumulate across millions of trades.
You will also align the Order struct to a 64-byte boundary. 64 bytes is the L1 cache line size on all modern x86-64 processors (Intel and AMD). Aligning to this boundary prevents "false sharing" where two unrelated objects on the same cache line force unnecessary memory traffic.
- Open include/types.hpp in your editor and replace the placeholder comment with the following code:
#pragma once
#include <cstdint>
#include <cstddef>
#include <new>
// Use the C++17 standard constant if available, otherwise fall back to 64 bytes
#ifdef __cpp_lib_hardware_interference_size
inline constexpr std::size_t cache_line_size = std::hardware_destructive_interference_size;
#else
inline constexpr std::size_t cache_line_size = 64;
#endif
// Which side of the book this order belongs to
enum class Side : uint8_t { Buy, Sell };
// Whether the order rests in the book or fills immediately
enum class OrderType : uint8_t { Limit, Market };
What does this code do?
The #ifdef __cpp_lib_hardware_interference_size block checks whether your compiler provides the C++17 constant for the hardware cache line size. GCC often does not define it due to ABI concerns, so the #else branch falls back to 64, which is correct for all modern x86-64 chips.
The Side and OrderType enums use uint8_t as the underlying type. This keeps them to 1 byte each, packing tightly inside the Order struct.
You won't see a change yet. The Order struct in the next chunk uses these types.
- Directly below the OrderType enum, add the Order struct:
// Each order is aligned to one full cache line to prevent false sharing
struct alignas(cache_line_size) Order {
uint64_t order_id;
Side side;
OrderType type;
int64_t price; // in ticks (e.g., 15025 = $150.25)
uint32_t quantity;
uint32_t remaining_quantity;
uint64_t timestamp; // nanoseconds from clock_gettime
// Intrusive linked list pointers for the PriceLevel FIFO queue
Order* prev = nullptr;
Order* next = nullptr;
bool is_filled() const { return remaining_quantity == 0; }
};
What do these fields mean in market terms?
- price is stored as int64_t in ticks. $150.25 becomes 15025. This is industry standard at CME, NASDAQ, and NYSE.
- remaining_quantity tracks how much of the order is still unfilled. A 100-share order partially filled for 30 shares has remaining_quantity = 70.
- prev and next make this an intrusive linked list node. Orders at the same price are chained together for FIFO matching.
- alignas(cache_line_size) ensures each Order starts on a 64-byte boundary. The CPU fetches entire cache lines at once, so alignment prevents one Order's data from straddling two cache lines.
You won't build yet. One more small struct completes this file.
- Below the Order struct, add the Trade struct that represents a completed match:
// Output of the matching engine when two orders cross
struct Trade {
uint64_t buy_order_id;
uint64_t sell_order_id;
int64_t price;
uint32_t quantity;
};
What is a Trade?
When a buy order's price meets or exceeds a sell order's price, the matching engine produces a Trade. It records which orders matched, at what price (the resting order's price), and for how many shares.
✔️ Awesome, I've got everything!
Great. Save include/types.hpp before moving on.
ⓧ I'd like to double check the full code
#pragma once
#include <cstdint>
#include <cstddef>
#include <new>
#ifdef __cpp_lib_hardware_interference_size
inline constexpr std::size_t cache_line_size = std::hardware_destructive_interference_size;
#else
inline constexpr std::size_t cache_line_size = 64;
#endif
enum class Side : uint8_t { Buy, Sell };
enum class OrderType : uint8_t { Limit, Market };
struct alignas(cache_line_size) Order {
uint64_t order_id;
Side side;
OrderType type;
int64_t price; // in ticks (e.g., 15025 = $150.25)
uint32_t quantity;
uint32_t remaining_quantity;
uint64_t timestamp; // nanoseconds from clock_gettime
Order* prev = nullptr;
Order* next = nullptr;
bool is_filled() const { return remaining_quantity == 0; }
};
struct Trade {
uint64_t buy_order_id;
uint64_t sell_order_id;
int64_t price;
uint32_t quantity;
};
- Now update src/main.cpp to include the new header. Find the line:
#include <cstdio>
- Add #include "types.hpp" above it, so the top of the file now looks like this:
#include "types.hpp"
#include <cstdio>
- Save src/main.cpp and rebuild the project by running:
cmake --build build
- You should see the build succeed with no errors. The compiler now knows about your Order and Trade types.
Seeing a compilation error?
- If you see "file not found" for types.hpp, confirm the file is in the include/ directory and that your CMakeLists.txt has target_include_directories(hft_orderbook PRIVATE include).
- If you see errors about uint64_t or uint8_t, make sure #include <cstdint> is at the top of your file.
- If you see "unknown type name 'cache_line_size'", check that the #ifdef block defining it appears before the Order struct.
Help me debug my types.hpp compilation error
Define the PriceLevel struct
In a real exchange, multiple orders can sit at the same price. These orders form a queue. The first order to arrive at that price is the first to get matched. This is called time priority.
The PriceLevel struct holds all orders at a single price in a doubly-linked list. New orders go to the back. Matches and cancellations remove orders from anywhere in the list.
- Open include/order_book.hpp and replace the placeholder with the file preamble and PriceLevel struct:
#pragma once
#include "types.hpp"
#include "memory_pool.hpp"
#include <map>
#include <unordered_map>
#include <vector>
#include <functional>
struct PriceLevel {
int64_t price = 0;
uint32_t total_quantity = 0;
uint32_t order_count = 0;
Order* head = nullptr; // front of the FIFO queue
Order* tail = nullptr; // back of the FIFO queue
// Add an order to the back of this price level's queue
void push_back(Order* order) {
order->prev = tail;
order->next = nullptr;
if (tail) {
tail->next = order;
} else {
head = order;
}
tail = order;
total_quantity += order->remaining_quantity;
++order_count;
}
// Remove an order from anywhere in this price level's queue
void remove(Order* order) {
if (order->prev) {
order->prev->next = order->next;
} else {
head = order->next;
}
if (order->next) {
order->next->prev = order->prev;
} else {
tail = order->prev;
}
total_quantity -= order->remaining_quantity;
--order_count;
order->prev = nullptr;
order->next = nullptr;
}
bool empty() const { return head == nullptr; }
};
Why an intrusive linked list instead of std::list?
A std::list allocates a separate heap node for every element. Each allocation takes 100+ nanoseconds due to lock contention in the allocator. An intrusive list embeds the prev/next pointers directly inside the Order struct. Zero extra allocations.
push_back appends to the tail (preserving arrival order). remove unlinks from any position in O(1) by updating the neighbor pointers. This is essential for fast cancellations.
You won't build yet. The OrderBook class below completes this file.
Define the OrderBook class
An order book has two sides: bids (buy orders) and asks (sell orders). The best bid is the highest price someone is willing to pay. The best ask is the lowest price someone is willing to sell at. The gap between them is the bid-ask spread.
- Below the PriceLevel struct in include/order_book.hpp, add the OrderBook class declaration:
class OrderBook {
public:
static constexpr std::size_t MAX_ORDERS = 1'000'000;
OrderBook() = default;
private:
// Bids: descending price (std::greater gives highest price first)
std::map<int64_t, PriceLevel, std::greater<int64_t>> bids_;
// Asks: ascending price (default std::less gives lowest price first)
std::map<int64_t, PriceLevel> asks_;
std::unordered_map<uint64_t, Order*> order_map_;
};
Why std::map with different comparators?
bids_ uses std::greater<int64_t> so that bids_.begin() always points to the highest bid price. asks_ uses the default std::less so that asks_.begin() always points to the lowest ask price.
This means the matching engine can always find the best price on each side in O(1) by calling begin(). The order_map_ provides O(1) lookup from an order ID to its pointer, which is critical for fast cancellations.
✔️ Awesome, I've got everything!
Great. Save include/order_book.hpp before moving on.
ⓧ I'd like to double check the full code
#pragma once
#include "types.hpp"
#include "memory_pool.hpp"
#include <map>
#include <unordered_map>
#include <vector>
#include <functional>
struct PriceLevel {
int64_t price = 0;
uint32_t total_quantity = 0;
uint32_t order_count = 0;
Order* head = nullptr;
Order* tail = nullptr;
void push_back(Order* order) {
order->prev = tail;
order->next = nullptr;
if (tail) {
tail->next = order;
} else {
head = order;
}
tail = order;
total_quantity += order->remaining_quantity;
++order_count;
}
void remove(Order* order) {
if (order->prev) {
order->prev->next = order->next;
} else {
head = order->next;
}
if (order->next) {
order->next->prev = order->prev;
} else {
tail = order->prev;
}
total_quantity -= order->remaining_quantity;
--order_count;
order->prev = nullptr;
order->next = nullptr;
}
bool empty() const { return head == nullptr; }
};
class OrderBook {
public:
static constexpr std::size_t MAX_ORDERS = 1'000'000;
OrderBook() = default;
private:
std::map<int64_t, PriceLevel, std::greater<int64_t>> bids_;
std::map<int64_t, PriceLevel> asks_;
std::unordered_map<uint64_t, Order*> order_map_;
};
- Update src/main.cpp to include the order book header. Find the line:
#include "types.hpp"
#include <cstdio>
- Add #include "order_book.hpp" between the two existing includes, so the top of the file looks like this:
#include "types.hpp"
#include "order_book.hpp"
#include <cstdio>
- Save src/main.cpp and rebuild by running:
cmake --build build
- The build should succeed with zero errors. Your order book data structures are now compiled and type-checked.
Build failing after adding order_book.hpp?
- If you see errors about Order being undefined inside order_book.hpp, make sure the first include in that file is #include "types.hpp".
- If you see errors about std::map or std::greater, confirm you have #include <map> and #include <functional> in your includes.
- Warnings from memory_pool.hpp are expected. That file is still a placeholder and its include is harmless since the placeholder is just a comment.
Help me fix my order_book.hpp build errors
Your core data structures are locked in. Next up, you will write the matching engine that brings this order book to life by executing trades whenever buy and sell prices cross.
Implement the Matching Engine
Your data structures are defined and your project builds. Now it is time to bring the order book to life with the actual trading logic.
A matching engine is the core of every electronic exchange. When a new order arrives, it checks whether any existing orders on the opposite side of the book can fill it. The algorithm that determines who gets filled first is called price-time priority: best price wins, and among orders at the same price, the one that arrived first wins.
In this step, get ready to:
- Build a zero-allocation memory pool that eliminates heap allocation on the critical path.
- Implement the price-time priority matching loop with partial and full fill support.
- Add order cancellation with O(1) lookup by order ID.
Implement the zero-allocation memory pool
Before writing the matching logic, you need a way to create Order objects without calling the system allocator. In production HFT systems, a single call to malloc can spike tail latency due to lock contention inside the allocator's arenas. The solution is a fixed-size pool that pre-allocates all memory at startup.
- Open include/memory_pool.hpp in your editor and replace the placeholder comment with the following code:
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#include <stdexcept>
template<typename T, std::size_t N>
class FixedPool {
public:
FixedPool() {
// Pre-allocate N objects in a contiguous block
storage_.resize(N);
// Build the free-list (stack of available pointers)
free_list_.reserve(N);
for (std::size_t i = N; i > 0; --i) {
free_list_.push_back(&storage_[i - 1]);
}
}
// O(1) allocation: pop from free-list, no system call
T* allocate() {
if (free_list_.empty()) {
throw std::runtime_error("FixedPool exhausted");
}
T* ptr = free_list_.back();
free_list_.pop_back();
return ptr;
}
// O(1) deallocation: push back onto free-list
void deallocate(T* ptr) {
free_list_.push_back(ptr);
}
std::size_t available() const { return free_list_.size(); }
std::size_t capacity() const { return N; }
private:
std::vector<T> storage_; // contiguous block of N T objects
std::vector<T*> free_list_; // stack of available slots
};
What does this code do?
The FixedPool template pre-allocates a contiguous array of N objects at construction time. After that, every allocate() call just pops a pointer off the free-list. Every deallocate() call pushes it back.
Both operations are O(1) with zero system calls. No new or delete ever runs on the hot path. The storage is contiguous in memory, which keeps cache performance predictable.
Why pre-allocate instead of using new?
The default system allocator (malloc/new) uses internal locks to manage its free memory arenas. Under production load, multiple threads competing for the same arena lock cause P99 latency spikes that can reach tens of microseconds. A pre-allocated pool eliminates this entirely because no kernel involvement is needed during operation.
- Save include/memory_pool.hpp.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#include <stdexcept>
template<typename T, std::size_t N>
class FixedPool {
public:
FixedPool() {
storage_.resize(N);
free_list_.reserve(N);
for (std::size_t i = N; i > 0; --i) {
free_list_.push_back(&storage_[i - 1]);
}
}
T* allocate() {
if (free_list_.empty()) {
throw std::runtime_error("FixedPool exhausted");
}
T* ptr = free_list_.back();
free_list_.pop_back();
return ptr;
}
void deallocate(T* ptr) {
free_list_.push_back(ptr);
}
std::size_t available() const { return free_list_.size(); }
std::size_t capacity() const { return N; }
private:
std::vector<T> storage_;
std::vector<T*> free_list_;
};
Getting a compile error about stdexcept?
Make sure the #include <stdexcept> line is at the top of the file. Without it, std::runtime_error is undefined.
help me debug a compile error in memory_pool.hpp
Implement price-time priority matching
The matching engine is where trading happens. When a buy order arrives, it scans the ask side for prices at or below the buy price. When a sell order arrives, it scans the bid side. At each price level, orders are matched FIFO (first in, first out), which implements time priority.
- Open include/order_book.hpp and replace the entire OrderBook class (from class OrderBook { to the closing };) with the following implementation:
class OrderBook {
public:
static constexpr std::size_t MAX_ORDERS = 1'000'000;
OrderBook() = default;
// Entry point: submit a new order to the book
std::vector<Trade> add_order(uint64_t order_id, Side side, OrderType type,
int64_t price, uint32_t quantity, uint64_t timestamp) {
std::vector<Trade> trades;
// Allocate from pool (zero heap allocation)
Order* order = pool_.allocate();
*order = Order{order_id, side, type, price, quantity, quantity, timestamp,
nullptr, nullptr};
// Market orders use extreme prices to guarantee matching
if (type == OrderType::Market) {
order->price = (side == Side::Buy) ? INT64_MAX : 0;
}
// Attempt to match against the opposite side
match(order, trades);
// Handle the remainder after matching
if (!order->is_filled() && type == OrderType::Limit) {
// Resting limit order: place in the book
insert_into_book(order);
order_map_[order_id] = order;
} else if (order->is_filled()) {
// Fully filled: return memory to pool
pool_.deallocate(order);
} else {
// Market order that could not fully fill
pool_.deallocate(order);
}
return trades;
}
// Cancel an existing order by ID (O(1) lookup)
bool cancel_order(uint64_t order_id) {
auto it = order_map_.find(order_id);
if (it == order_map_.end()) return false;
Order* order = it->second;
Side side = order->side;
int64_t price = order->price;
// Remove from the correct side of the book
if (side == Side::Buy) {
auto level_it = bids_.find(price);
if (level_it != bids_.end()) {
level_it->second.remove(order);
if (level_it->second.empty()) {
bids_.erase(level_it);
}
}
} else {
auto level_it = asks_.find(price);
if (level_it != asks_.end()) {
level_it->second.remove(order);
if (level_it->second.empty()) {
asks_.erase(level_it);
}
}
}
order_map_.erase(it);
pool_.deallocate(order);
return true;
}
// Accessors for book state
int64_t best_bid() const {
if (bids_.empty()) return 0;
return bids_.begin()->first;
}
int64_t best_ask() const {
if (asks_.empty()) return INT64_MAX;
return asks_.begin()->first;
}
int64_t spread() const {
return best_ask() - best_bid();
}
std::size_t bid_levels() const { return bids_.size(); }
std::size_t ask_levels() const { return asks_.size(); }
private:
// Dispatch to side-specific matching
void match(Order* incoming, std::vector<Trade>& trades) {
if (incoming->side == Side::Buy) {
match_buy(incoming, trades);
} else {
match_sell(incoming, trades);
}
}
// Match a buy order against the ask side
void match_buy(Order* buy, std::vector<Trade>& trades) {
while (buy->remaining_quantity > 0 && !asks_.empty()) {
auto best_ask_it = asks_.begin();
// For limit orders, stop if buy price < best ask
if (buy->price < best_ask_it->first && buy->type == OrderType::Limit) {
break;
}
PriceLevel& level = best_ask_it->second;
Order* resting = level.head; // FIFO: oldest order first
// Fill the smaller of the two quantities
uint32_t fill_qty = std::min(buy->remaining_quantity, resting->remaining_quantity);
buy->remaining_quantity -= fill_qty;
resting->remaining_quantity -= fill_qty;
// Record the trade at the resting order's price (maker sets price)
trades.push_back(Trade{buy->order_id, resting->order_id,
resting->price, fill_qty});
if (resting->is_filled()) {
// Full fill: remove from level and return to pool
level.remove(resting);
order_map_.erase(resting->order_id);
pool_.deallocate(resting);
} else {
// Partial fill: update level quantity
level.total_quantity -= fill_qty;
}
// Clean up empty price levels
if (level.empty()) {
asks_.erase(best_ask_it);
}
}
}
// Match a sell order against the bid side
void match_sell(Order* sell, std::vector<Trade>& trades) {
while (sell->remaining_quantity > 0 && !bids_.empty()) {
auto best_bid_it = bids_.begin();
// For limit orders, stop if sell price > best bid
if (sell->price > best_bid_it->first && sell->type == OrderType::Limit) {
break;
}
PriceLevel& level = best_bid_it->second;
Order* resting = level.head; // FIFO: oldest order first
uint32_t fill_qty = std::min(sell->remaining_quantity, resting->remaining_quantity);
sell->remaining_quantity -= fill_qty;
resting->remaining_quantity -= fill_qty;
// Trade recorded with buy_order_id first
trades.push_back(Trade{resting->order_id, sell->order_id,
resting->price, fill_qty});
if (resting->is_filled()) {
level.remove(resting);
order_map_.erase(resting->order_id);
pool_.deallocate(resting);
} else {
level.total_quantity -= fill_qty;
}
if (level.empty()) {
bids_.erase(best_bid_it);
}
}
}
// Place a resting order into the correct side
void insert_into_book(Order* order) {
if (order->side == Side::Buy) {
bids_[order->price].push_back(order);
} else {
asks_[order->price].push_back(order);
}
}
// Bids: descending price (std::greater gives highest price first)
std::map<int64_t, PriceLevel, std::greater<int64_t>> bids_;
// Asks: ascending price (default std::less gives lowest price first)
std::map<int64_t, PriceLevel> asks_;
std::unordered_map<uint64_t, Order*> order_map_;
FixedPool<Order, MAX_ORDERS> pool_;
};
What does this code do?
This is the complete matching engine. Here is how the key methods work:
- add_order() allocates an Order from the pool, sets market order prices to extremes (INT64_MAX for buys, 0 for sells) to guarantee matching, then calls match(). Unfilled limit orders rest in the book. Filled orders return memory to the pool.
- match_buy() loops through the ask side starting at the lowest price. It fills the minimum of the two quantities (partial fill support). The trade executes at the resting order's price because the maker sets the price.
- match_sell() mirrors match_buy() but sweeps the bid side from highest price down.
- cancel_order() uses order_map_ (an unordered_map) for O(1) lookup by ID, then unlinks the order from its price level's FIFO queue.
- best_bid() and best_ask() return the top-of-book price from each side. The spread() is the gap between them.
Why does the maker set the trade price?
The "resting" order (the one already in the book) is called the maker because it provides liquidity. The incoming order that matches against it is the taker. On every exchange, the trade executes at the maker's price. This is why resting->price is recorded in the Trade struct, not the incoming order's price.
- Save include/order_book.hpp.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on to the build step.
ⓧ I'd like to double check the full code
#pragma once
#include "types.hpp"
#include "memory_pool.hpp"
#include <map>
#include <unordered_map>
#include <vector>
#include <functional>
struct PriceLevel {
int64_t price = 0;
uint32_t total_quantity = 0;
uint32_t order_count = 0;
Order* head = nullptr;
Order* tail = nullptr;
void push_back(Order* order) {
order->prev = tail;
order->next = nullptr;
if (tail) {
tail->next = order;
} else {
head = order;
}
tail = order;
total_quantity += order->remaining_quantity;
++order_count;
}
void remove(Order* order) {
if (order->prev) {
order->prev->next = order->next;
} else {
head = order->next;
}
if (order->next) {
order->next->prev = order->prev;
} else {
tail = order->prev;
}
total_quantity -= order->remaining_quantity;
--order_count;
order->prev = nullptr;
order->next = nullptr;
}
bool empty() const { return head == nullptr; }
};
class OrderBook {
public:
static constexpr std::size_t MAX_ORDERS = 1'000'000;
OrderBook() = default;
std::vector<Trade> add_order(uint64_t order_id, Side side, OrderType type,
int64_t price, uint32_t quantity, uint64_t timestamp) {
std::vector<Trade> trades;
Order* order = pool_.allocate();
*order = Order{order_id, side, type, price, quantity, quantity, timestamp,
nullptr, nullptr};
if (type == OrderType::Market) {
order->price = (side == Side::Buy) ? INT64_MAX : 0;
}
match(order, trades);
if (!order->is_filled() && type == OrderType::Limit) {
insert_into_book(order);
order_map_[order_id] = order;
} else if (order->is_filled()) {
pool_.deallocate(order);
} else {
pool_.deallocate(order);
}
return trades;
}
bool cancel_order(uint64_t order_id) {
auto it = order_map_.find(order_id);
if (it == order_map_.end()) return false;
Order* order = it->second;
Side side = order->side;
int64_t price = order->price;
if (side == Side::Buy) {
auto level_it = bids_.find(price);
if (level_it != bids_.end()) {
level_it->second.remove(order);
if (level_it->second.empty()) {
bids_.erase(level_it);
}
}
} else {
auto level_it = asks_.find(price);
if (level_it != asks_.end()) {
level_it->second.remove(order);
if (level_it->second.empty()) {
asks_.erase(level_it);
}
}
}
order_map_.erase(it);
pool_.deallocate(order);
return true;
}
int64_t best_bid() const {
if (bids_.empty()) return 0;
return bids_.begin()->first;
}
int64_t best_ask() const {
if (asks_.empty()) return INT64_MAX;
return asks_.begin()->first;
}
int64_t spread() const {
return best_ask() - best_bid();
}
std::size_t bid_levels() const { return bids_.size(); }
std::size_t ask_levels() const { return asks_.size(); }
private:
void match(Order* incoming, std::vector<Trade>& trades) {
if (incoming->side == Side::Buy) {
match_buy(incoming, trades);
} else {
match_sell(incoming, trades);
}
}
void match_buy(Order* buy, std::vector<Trade>& trades) {
while (buy->remaining_quantity > 0 && !asks_.empty()) {
auto best_ask_it = asks_.begin();
if (buy->price < best_ask_it->first && buy->type == OrderType::Limit) {
break;
}
PriceLevel& level = best_ask_it->second;
Order* resting = level.head;
uint32_t fill_qty = std::min(buy->remaining_quantity, resting->remaining_quantity);
buy->remaining_quantity -= fill_qty;
resting->remaining_quantity -= fill_qty;
trades.push_back(Trade{buy->order_id, resting->order_id,
resting->price, fill_qty});
if (resting->is_filled()) {
level.remove(resting);
order_map_.erase(resting->order_id);
pool_.deallocate(resting);
} else {
level.total_quantity -= fill_qty;
}
if (level.empty()) {
asks_.erase(best_ask_it);
}
}
}
void match_sell(Order* sell, std::vector<Trade>& trades) {
while (sell->remaining_quantity > 0 && !bids_.empty()) {
auto best_bid_it = bids_.begin();
if (sell->price > best_bid_it->first && sell->type == OrderType::Limit) {
break;
}
PriceLevel& level = best_bid_it->second;
Order* resting = level.head;
uint32_t fill_qty = std::min(sell->remaining_quantity, resting->remaining_quantity);
sell->remaining_quantity -= fill_qty;
resting->remaining_quantity -= fill_qty;
trades.push_back(Trade{resting->order_id, sell->order_id,
resting->price, fill_qty});
if (resting->is_filled()) {
level.remove(resting);
order_map_.erase(resting->order_id);
pool_.deallocate(resting);
} else {
level.total_quantity -= fill_qty;
}
if (level.empty()) {
bids_.erase(best_bid_it);
}
}
}
void insert_into_book(Order* order) {
if (order->side == Side::Buy) {
bids_[order->price].push_back(order);
} else {
asks_[order->price].push_back(order);
}
}
// Bids: descending price (std::greater gives highest price first)
std::map<int64_t, PriceLevel, std::greater<int64_t>> bids_;
// Asks: ascending price (default std::less gives lowest price first)
std::map<int64_t, PriceLevel> asks_;
std::unordered_map<uint64_t, Order*> order_map_;
FixedPool<Order, MAX_ORDERS> pool_;
};
Seeing errors about INT64_MAX?
The INT64_MAX macro comes from <cstdint> which is included transitively through types.hpp. Make sure your #include "types.hpp" is at the top of order_book.hpp.
help me fix INT64_MAX or other compile errors in my order_book.hpp
Build and verify the matching engine
Both files are saved. Time to compile the project and confirm everything links correctly.
- Rebuild the project by running these commands:
cmake -S . -B build && cmake --build build
- You should see the build complete with no errors. The last line of output will show the linker producing the hft_orderbook binary.
Build failing with linker errors?
If you see "undefined reference" errors, make sure memory_pool.hpp is saved and that order_book.hpp includes it with #include "memory_pool.hpp". Both headers are in the include/ directory.
If you see template-related errors, verify you kept the FixedPool<Order, MAX_ORDERS> pool_; member declaration at the bottom of the OrderBook class.
help me debug build errors in my HFT order book project
Your matching engine compiles and is ready to process orders. Next up, you will build a benchmark harness that measures exactly how fast each operation runs with nanosecond precision.
Benchmark with Nanosecond Precision
You have a fully functional order book and matching engine. Now you need to prove it's fast.
In HFT, performance claims mean nothing without measurements. In this step, you will build a benchmark harness that measures per-operation latency with nanosecond precision and produces a latency percentile histogram.
In this step, get ready to:
- Write a nanosecond-precision timing function using clock_gettime(CLOCK_MONOTONIC).
- Compute latency percentiles (p50, p90, p99, p99.9) and throughput.
- Run a 100,000-operation benchmark and analyze your matching engine's performance.
Write the benchmark harness with nanosecond timing
The Linux kernel provides clock_gettime(CLOCK_MONOTONIC) for high-resolution timing that is not affected by system clock adjustments. On Linux (including WSL2), it delivers 1-nanosecond resolution.
- Open include/benchmark.hpp and replace the placeholder comment with this timing function:
#pragma once
#include <cstdint>
#include <time.h>
#include <vector>
#include <algorithm>
#include <cstdio>
#include <numeric>
// Returns current time in nanoseconds from a monotonic clock
inline uint64_t now_ns() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<uint64_t>(ts.tv_sec) * 1'000'000'000ULL
+ static_cast<uint64_t>(ts.tv_nsec);
}
What does this code do?
The now_ns() function calls the kernel's monotonic clock and converts the result into a single uint64_t nanosecond count.
CLOCK_MONOTONIC is the right clock for benchmarking because it never jumps backwards (unlike the wall clock, which can shift due to NTP adjustments). The inline keyword tells the compiler to embed this directly at the call site, avoiding function-call overhead in the measurement itself.
You won't see output yet. The next chunk adds the statistics functions that process the timing data.
Now you need the benchmark loop that exercises your matching engine. This is the main program that generates a realistic order mix and times each operation.
- Open src/main.cpp and replace the entire file contents with this benchmark program:
#include "types.hpp"
#include "order_book.hpp"
#include "benchmark.hpp"
#include <cstdio>
#include <cstdlib>
#include <random>
#include <vector>
int main() {
std::printf("HFT Order Book - Matching Engine Benchmark\n");
std::printf("==========================================\n\n");
// Print cache line info
std::printf("Cache line size: %zu bytes\n", cache_line_size);
std::printf("sizeof(Order): %zu bytes\n", sizeof(Order));
std::printf("Order pool capacity: %zu\n\n", OrderBook::MAX_ORDERS);
OrderBook book;
// Parameters
constexpr std::size_t NUM_OPS = 100'000;
constexpr int64_t BASE_PRICE = 10000; // $100.00 in ticks
constexpr int64_t PRICE_RANGE = 100; // +/- $1.00
std::mt19937_64 rng(42);
std::uniform_int_distribution<int64_t> price_dist(BASE_PRICE - PRICE_RANGE,
BASE_PRICE + PRICE_RANGE);
std::uniform_int_distribution<uint32_t> qty_dist(1, 100);
std::uniform_int_distribution<int> action_dist(0, 99);
std::vector<uint64_t> latencies;
latencies.reserve(NUM_OPS);
std::vector<uint64_t> active_order_ids;
active_order_ids.reserve(NUM_OPS);
uint64_t next_order_id = 1;
std::printf("Running %zu operations...\n\n", NUM_OPS);
for (std::size_t i = 0; i < NUM_OPS; ++i) {
int action = action_dist(rng);
uint64_t start = now_ns();
if (action < 40) {
// 40% limit buy
int64_t price = price_dist(rng);
uint32_t qty = qty_dist(rng);
uint64_t id = next_order_id++;
book.add_order(id, Side::Buy, OrderType::Limit, price, qty, start);
active_order_ids.push_back(id);
} else if (action < 80) {
// 40% limit sell
int64_t price = price_dist(rng);
uint32_t qty = qty_dist(rng);
uint64_t id = next_order_id++;
book.add_order(id, Side::Sell, OrderType::Limit, price, qty, start);
active_order_ids.push_back(id);
} else if (action < 90) {
// 10% market order
Side side = (rng() % 2 == 0) ? Side::Buy : Side::Sell;
uint32_t qty = qty_dist(rng);
uint64_t id = next_order_id++;
book.add_order(id, side, OrderType::Market, 0, qty, start);
} else {
// 10% cancellation
if (!active_order_ids.empty()) {
std::uniform_int_distribution<std::size_t> idx_dist(0, active_order_ids.size() - 1);
std::size_t idx = idx_dist(rng);
book.cancel_order(active_order_ids[idx]);
active_order_ids[idx] = active_order_ids.back();
active_order_ids.pop_back();
}
}
uint64_t end = now_ns();
latencies.push_back(end - start);
}
LatencyStats stats = compute_stats(latencies);
print_stats(stats);
std::printf("\nBook state after benchmark:\n");
std::printf(" Bid levels: %zu\n", book.bid_levels());
std::printf(" Ask levels: %zu\n", book.ask_levels());
std::printf(" Best bid: %ld ($%.2f)\n", book.best_bid(),
book.best_bid() / 100.0);
std::printf(" Best ask: %ld ($%.2f)\n", book.best_ask(),
book.best_ask() / 100.0);
std::printf(" Spread: %ld ticks ($%.2f)\n", book.spread(),
book.spread() / 100.0);
return 0;
}
What does this benchmark do?
The benchmark generates a realistic order flow: 40% limit buys, 40% limit sells, 10% market orders, and 10% cancellations. This mix exercises every code path in your matching engine.
- The RNG seed is fixed at 42 so results are reproducible across runs.
- Prices cluster around BASE_PRICE = 10000 (representing $100.00 in ticks) with +/- 100 ticks of spread, forcing frequent matching.
- Each operation is wrapped with now_ns() calls to capture per-operation latency into the latencies vector.
- The active_order_ids vector tracks which orders are still in the book, so cancellations target valid orders.
- Save src/main.cpp.
The project will not compile yet because main.cpp calls compute_stats() and print_stats() which do not exist in benchmark.hpp yet. You will add those next.
Compute and print the latency histogram
Raw latency samples are useful, but what you really need are percentiles. A p99 of 800ns means 99% of operations completed in under 800 nanoseconds. This is the standard way HFT firms report performance.
- Open include/benchmark.hpp and add the following below the now_ns() function:
struct LatencyStats {
uint64_t min_ns;
uint64_t max_ns;
double mean_ns;
uint64_t p50_ns;
uint64_t p90_ns;
uint64_t p99_ns;
uint64_t p999_ns;
double throughput_ops_per_sec;
std::size_t sample_count;
};
inline LatencyStats compute_stats(std::vector<uint64_t>& samples) {
if (samples.empty()) return {};
std::sort(samples.begin(), samples.end());
std::size_t n = samples.size();
double sum = std::accumulate(samples.begin(), samples.end(), 0.0);
LatencyStats stats;
stats.sample_count = n;
stats.min_ns = samples.front();
stats.max_ns = samples.back();
stats.mean_ns = sum / static_cast<double>(n);
stats.p50_ns = samples[n * 50 / 100];
stats.p90_ns = samples[n * 90 / 100];
stats.p99_ns = samples[n * 99 / 100];
stats.p999_ns = samples[n * 999 / 1000];
double total_time_sec = sum / 1'000'000'000.0;
stats.throughput_ops_per_sec = static_cast<double>(n) / total_time_sec;
return stats;
}
inline void print_stats(const LatencyStats& stats) {
std::printf("=== Latency Report (%zu operations) ===\n", stats.sample_count);
std::printf(" Min: %lu ns\n", stats.min_ns);
std::printf(" Mean: %.1f ns\n", stats.mean_ns);
std::printf(" P50: %lu ns\n", stats.p50_ns);
std::printf(" P90: %lu ns\n", stats.p90_ns);
std::printf(" P99: %lu ns\n", stats.p99_ns);
std::printf(" P99.9: %lu ns\n", stats.p999_ns);
std::printf(" Max: %lu ns\n", stats.max_ns);
std::printf(" Throughput: %.0f ops/sec\n", stats.throughput_ops_per_sec);
}
What does this code do?
- compute_stats() sorts the latency samples, then indexes directly into the sorted array for each percentile. samples[n * 99 / 100] gives the p99 value because 99% of all samples fall below that index.
- Throughput is calculated as total operations divided by total elapsed time (the sum of all individual latencies, converted to seconds).
- print_stats() formats the report using std::printf for predictable output without the overhead of iostream.
- Save include/benchmark.hpp.
Seeing a compile error about accumulate?
Make sure you have #include <numeric> at the top of benchmark.hpp. The std::accumulate function lives in that header.
Also confirm #include <algorithm> is present for std::sort.
Help me fix a compile error in benchmark.hpp
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete include/benchmark.hpp:
#pragma once
#include <cstdint>
#include <time.h>
#include <vector>
#include <algorithm>
#include <cstdio>
#include <numeric>
inline uint64_t now_ns() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<uint64_t>(ts.tv_sec) * 1'000'000'000ULL
+ static_cast<uint64_t>(ts.tv_nsec);
}
struct LatencyStats {
uint64_t min_ns;
uint64_t max_ns;
double mean_ns;
uint64_t p50_ns;
uint64_t p90_ns;
uint64_t p99_ns;
uint64_t p999_ns;
double throughput_ops_per_sec;
std::size_t sample_count;
};
inline LatencyStats compute_stats(std::vector<uint64_t>& samples) {
if (samples.empty()) return {};
std::sort(samples.begin(), samples.end());
std::size_t n = samples.size();
double sum = std::accumulate(samples.begin(), samples.end(), 0.0);
LatencyStats stats;
stats.sample_count = n;
stats.min_ns = samples.front();
stats.max_ns = samples.back();
stats.mean_ns = sum / static_cast<double>(n);
stats.p50_ns = samples[n * 50 / 100];
stats.p90_ns = samples[n * 90 / 100];
stats.p99_ns = samples[n * 99 / 100];
stats.p999_ns = samples[n * 999 / 1000];
double total_time_sec = sum / 1'000'000'000.0;
stats.throughput_ops_per_sec = static_cast<double>(n) / total_time_sec;
return stats;
}
inline void print_stats(const LatencyStats& stats) {
std::printf("=== Latency Report (%zu operations) ===\n", stats.sample_count);
std::printf(" Min: %lu ns\n", stats.min_ns);
std::printf(" Mean: %.1f ns\n", stats.mean_ns);
std::printf(" P50: %lu ns\n", stats.p50_ns);
std::printf(" P90: %lu ns\n", stats.p90_ns);
std::printf(" P99: %lu ns\n", stats.p99_ns);
std::printf(" P99.9: %lu ns\n", stats.p999_ns);
std::printf(" Max: %lu ns\n", stats.max_ns);
std::printf(" Throughput: %.0f ops/sec\n", stats.throughput_ops_per_sec);
}
And here is the complete src/main.cpp:
#include "types.hpp"
#include "order_book.hpp"
#include "benchmark.hpp"
#include <cstdio>
#include <cstdlib>
#include <random>
#include <vector>
int main() {
std::printf("HFT Order Book - Matching Engine Benchmark\n");
std::printf("==========================================\n\n");
// Print cache line info
std::printf("Cache line size: %zu bytes\n", cache_line_size);
std::printf("sizeof(Order): %zu bytes\n", sizeof(Order));
std::printf("Order pool capacity: %zu\n\n", OrderBook::MAX_ORDERS);
OrderBook book;
// Parameters
constexpr std::size_t NUM_OPS = 100'000;
constexpr int64_t BASE_PRICE = 10000; // $100.00 in ticks
constexpr int64_t PRICE_RANGE = 100; // +/- $1.00
std::mt19937_64 rng(42);
std::uniform_int_distribution<int64_t> price_dist(BASE_PRICE - PRICE_RANGE,
BASE_PRICE + PRICE_RANGE);
std::uniform_int_distribution<uint32_t> qty_dist(1, 100);
std::uniform_int_distribution<int> action_dist(0, 99);
std::vector<uint64_t> latencies;
latencies.reserve(NUM_OPS);
std::vector<uint64_t> active_order_ids;
active_order_ids.reserve(NUM_OPS);
uint64_t next_order_id = 1;
std::printf("Running %zu operations...\n\n", NUM_OPS);
for (std::size_t i = 0; i < NUM_OPS; ++i) {
int action = action_dist(rng);
uint64_t start = now_ns();
if (action < 40) {
// 40% limit buy
int64_t price = price_dist(rng);
uint32_t qty = qty_dist(rng);
uint64_t id = next_order_id++;
book.add_order(id, Side::Buy, OrderType::Limit, price, qty, start);
active_order_ids.push_back(id);
} else if (action < 80) {
// 40% limit sell
int64_t price = price_dist(rng);
uint32_t qty = qty_dist(rng);
uint64_t id = next_order_id++;
book.add_order(id, Side::Sell, OrderType::Limit, price, qty, start);
active_order_ids.push_back(id);
} else if (action < 90) {
// 10% market order
Side side = (rng() % 2 == 0) ? Side::Buy : Side::Sell;
uint32_t qty = qty_dist(rng);
uint64_t id = next_order_id++;
book.add_order(id, side, OrderType::Market, 0, qty, start);
} else {
// 10% cancellation
if (!active_order_ids.empty()) {
std::uniform_int_distribution<std::size_t> idx_dist(0, active_order_ids.size() - 1);
std::size_t idx = idx_dist(rng);
book.cancel_order(active_order_ids[idx]);
active_order_ids[idx] = active_order_ids.back();
active_order_ids.pop_back();
}
}
uint64_t end = now_ns();
latencies.push_back(end - start);
}
LatencyStats stats = compute_stats(latencies);
print_stats(stats);
std::printf("\nBook state after benchmark:\n");
std::printf(" Bid levels: %zu\n", book.bid_levels());
std::printf(" Ask levels: %zu\n", book.ask_levels());
std::printf(" Best bid: %ld ($%.2f)\n", book.best_bid(),
book.best_bid() / 100.0);
std::printf(" Best ask: %ld ($%.2f)\n", book.best_ask(),
book.best_ask() / 100.0);
std::printf(" Spread: %ld ticks ($%.2f)\n", book.spread(),
book.spread() / 100.0);
return 0;
}
Run the benchmark and analyze results
Your project is now complete. Time to build with optimizations and run the benchmark.
- Rebuild the project by running these commands from your project root:
cmake -S . -B build && cmake --build build
Build failing?
- If you see errors about LatencyStats or compute_stats being undefined, make sure you added the struct and functions to include/benchmark.hpp below the now_ns() function.
- If you see errors about mt19937_64 or uniform_int_distribution, confirm #include <random> is at the top of src/main.cpp.
Help me fix my benchmark build errors
- Run the benchmark by executing:
./build/hft_orderbook
You should see a latency report with min, mean, percentiles (p50 through p99.9), max, and throughput printed in nanoseconds. Below that, the book's final state shows bid/ask levels, best prices, and the spread.
What does "good" latency look like?
Real exchange matching engines target sub-microsecond latency. That means less than 1000ns per operation. If your p50 is below 1000ns, your engine is operating in the same latency ballpark as production systems.
Your numbers will be slightly higher than bare-metal Linux because WSL2 runs inside a Hyper-V VM. The relative performance (how the pool compares to heap allocation) is what matters most for demonstrating your understanding of the trade-offs.
Why these percentiles matter in HFT interviews
HFT firms care about tail latency (p99, p99.9) because a single slow operation during a price spike can mean a missed fill worth thousands of dollars. Your benchmark proves that even at the 99.9th percentile, your engine does not degrade catastrophically.
The memory pool you built in the previous step is a major contributor to consistent latency. Without it, occasional malloc calls would spike your p99 significantly due to lock contention and system calls.
Your matching engine is now fully benchmarked. You have hard numbers proving sub-microsecond performance on a cache-aligned, zero-allocation hot path. Next up, a Secret Mission that takes this to the next level with lock-free concurrency.
Secret mission
Add a Lock-Free SPSC Ring Buffer
Your matching engine currently processes orders synchronously on a single thread. Real exchange systems separate order ingestion from matching using a lock-free producer-consumer pipeline. Can you build one using atomics and memory ordering?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally inside WSL2, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, or delete them entirely.
Resources you used:
- The hft_orderbook project directory on your Desktop (source files, headers, CMakeLists.txt).
- The build/ directory inside the project (compiled binary and CMake cache).
Keep everything running
No action needed. Choose this if you want to keep experimenting with the matching engine, tweak parameters, or extend the SPSC pipeline further.
- Your project files stay in ~/Desktop/hft_orderbook.
- You can rebuild anytime with cmake --build build from the project directory.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk and consume no CPU or memory until you rebuild and run the binary again.
- Optionally, remove the build artifacts to free disk space while keeping your source code. Run this from the project directory:
rm -rf build/
- When you return, regenerate the build directory with cmake -S . -B build && cmake --build build.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes your source code, headers, and compiled binary permanently.
- Delete the entire project directory by running:
cd ~/Desktop
rm -rf hft_orderbook
- Confirm it is gone by running:
ls ~/Desktop
- You should no longer see hft_orderbook in the output.
Nice Work!
Nice Work!
You just built a production-grade limit order book and matching engine in C++20. This is the exact infrastructure that powers every electronic exchange, and the exact skill set that HFT firms test for in their interview loops.
You've learned how to:
- Build a complete limit order book with price-time priority matching, the core algorithm of every electronic exchange, while learning market microstructure fundamentals like order types, bid/ask spread, and the maker/taker model.
- Implement cache-line-aligned, zero-allocation data structures using a pre-allocated memory pool, demonstrating the hardware-aware programming ("mechanical sympathy") that separates HFT engineers from general-purpose developers.
- Measure per-operation latency with nanosecond precision using clock_gettime(CLOCK_MONOTONIC) and produce latency percentile histograms (p50, p90, p99, p99.9) to benchmark your engine's performance.
- Secret Mission: Built a lock-free SPSC ring buffer using atomic operations with acquire/release memory ordering, demonstrating the producer-consumer architecture used in real HFT systems.
Ready to quiz yourself?