Order book¶
slick::sim::OrderBook
(src/order_book/order_book.hpp)
wraps the external
slick-orderbook L3 book and adds the one thing the
simulator needs that a plain book does not: the ability to hold real-market liquidity and simulated
user orders in the same structure while keeping them straight.
Class shape¶
class OrderBook : public OrderBookL3,
public orderbook::IOrderBookObserver,
public std::enable_shared_from_this<OrderBook>
It inherits the L3 book, observes itself (createOrderBook calls
order_book_->addObserver(order_book_->shared_from_this())), and is also observed by the owning
Symbol via a separate OrderBookObserver. Self-observation is how the book maintains
feed_md_level_quantity_ without the mutation sites having to remember to update it.
OrderBookImpl<OrderBookType> is the concrete template, specialised for L2 and L3. Both live
adapters instantiate OrderBookImpl<OrderBookType::L2>; the L3 specialisation's populate* methods
are empty stubs.
Phantom orders versus simulator orders¶
This is the central concept.
When the market-data feed reports that the real venue has 5 BTC bid at 100,000, the simulator cannot
know which orders make up that 5 BTC. It synthesises a phantom order — a single anonymous order
for the whole level quantity, with a fresh id from utils::nextOrderId() — and inserts it into the
L3 book. When your client sends a bid for 1 BTC at the same price, that becomes a simulator order
inserted into the same price level, behind the phantom order in the queue.
The book distinguishes them by a simple rule:
An order is a simulator order if and only if it is in
orders_. Everything else in the L3 book is phantom.
std::unordered_map<uint64_t, Order*> orders_; // simulator orders only
std::unordered_map<std::string, Order*> orders_by_client_order_id_;
std::unordered_map<std::string, Order*> orders_by_order_id_;
std::unordered_map<price_t, qty_t> feed_md_level_quantity_; // phantom qty per level
findOrder(id) returning nullptr therefore means "this is a phantom order", and the code uses it
that way throughout — for example in HyperliquidExchange::applyPhantomLevelUpdate:
if (symbol->findOrder(order.order_id)) {
continue; // belongs to the simulator's own user — never touch it
}
Why feed_md_level_quantity_ exists¶
The L3 book's own level totals include both kinds of order. But when the feed says "this level is now
3 BTC", that 3 BTC describes only the real market — it knows nothing about your resting 1 BTC. To
apply the update correctly the code needs the phantom-only total, which is what
feed_md_level_quantity_ tracks and getMDLevelQty(price) exposes:
It is maintained in OrderBook::onOrderUpdate, the self-observer callback. For any order update where
findOrder(update.order_id) returns null, the delta is applied to the phantom totals — handling both
the same-price quantity change and the price-move case (subtract from old_price, add to price).
The incremental-update logic in both adapters then reads:
auto md_level_qty = book.getMDLevelQty(price);
if (target_qty > md_level_qty) {
// real market added liquidity — append a new phantom order at the back
book.addBookOrder(utils::nextOrderId(), book_side, price, target_qty - md_level_qty, …);
} else if (target_qty < md_level_qty) {
// real market pulled liquidity — shrink phantom orders from the back, skipping simulator orders
}
Reducing from the back is a deliberate worst-case assumption: the simulator cannot know which real orders were cancelled, so it assumes the ones that would have been behind you in the queue survived. That makes your simulated queue position pessimistic rather than optimistic.
clearMDOrders()¶
A full snapshot invalidates all phantom liquidity, but must not throw away your live orders.
clearMDOrders() handles that:
void clearMDOrders() {
// 1. remember every simulator order with leaves_quantity > 0
// 2. OrderBookL3::clear(); feed_md_level_quantity_.clear();
// 3. re-insert the remembered simulator orders at their original priority
}
Hyperliquid's processL2Snapshot calls it on every snapshot. Coinbase's onLevel2Snapshot calls the
plain clear() instead, which does discard resting simulator orders — an inconsistency between
the two adapters.
Order identity and lookups¶
Every simulator order carries three identifiers:
| Identifier | Type | Source | Used for |
|---|---|---|---|
id |
uint64_t |
utils::nextOrderId(), a global atomic starting at 1 |
The L3 book's key; what phantom orders also use |
order_id |
std::string |
Random Boost UUID in allocateOrder() |
The exchange order id the client sees |
client_order_id |
std::string |
Client-supplied | Client-side correlation |
Symbol::findOrderByClientOrderId is preferred over findOrderByOrderId at every call site — the
modify and cancel handlers only fall back to order_id when client_order_id is empty.
The two string indices are populated in addOrder only when non-empty, and must be erased in lockstep
with orders_. Four methods do that: deleteOrder(Order*, timestamp, …), deleteOrder(Order*),
modifyOrder (when leaves reaches 0), and executeOrder (likewise). Any new mutation path has to
maintain all three maps together.
Allocation¶
Orders come from a slick::ObjectPool<Order> sized to the book's buffer_size (65,536 by default):
Order* allocateOrder() {
auto* order = order_buffer_.allocate();
order->order_id = boost::lexical_cast<std::string>(boost::uuids::random_generator()());
order->id = utils::nextOrderId();
order->created_time = utils::get_current_time_ns();
order->last_update_time = order->created_time;
return order;
}
The pool recycles memory, so allocateOrder hands back a previously used Order with all its old
field values intact except the four it overwrites. Exchange::handleNewOrderRequest then assigns
symbol, side, type, TIF, price, quantity, leaves, status, client_order_id and user_id — but not
cum_quantity, avg_fill_price, num_fills, fee, reject_message, cancel_message or
edit_history. Those carry over from whatever order last occupied the slot.
freeOrder returns an order to the pool, called from Symbol::addOrder/modifyOrder when
leaves_quantity == 0 and unconditionally from Symbol::cancelOrder.
UUID generation cost
allocateOrder constructs a fresh boost::uuids::random_generator on every call, seeding a new
PRNG per order. For a project whose stated priority is performance this is an obvious hot-path
cost; a thread_local generator would avoid it.
Queue priority¶
static uint64_t nextOrderPriority() noexcept { return ++next_priority_; }
inline uint64_t OrderBook::next_priority_ = 0;
next_priority_ is a static member, so it is shared by every order book in the process — all
symbols, all venues. It is also a plain uint64_t incremented without atomics. Today that is safe
because only the exchange thread mutates books, but a second mutating thread would race.
Priority is assigned in addOrder and reassigned in modifyOrder on a price change. Phantom orders
get priorities from the same counter, which is what makes them queue correctly against simulator
orders.
Execution¶
executeOrder is overridden to keep the Order in step with the L3 book:
bool executeOrder(OrderId order_id, Quantity executed_quantity, uint64_t timestamp, …) override {
auto* order = findOrder(order_id);
if (order) {
order->leaves_quantity -= executed_quantity;
order->avg_fill_price = /* recomputed via double */;
order->cum_quantity += executed_quantity;
order->last_fill_price = order->price;
order->last_fill_qty = executed_quantity;
order->last_fill_time = timestamp;
order->num_fills += 1;
}
auto rt = OrderBookL3::executeOrder(order_id, executed_quantity, timestamp, …);
if (order && order->leaves_quantity == 0) { /* erase from all three maps */ }
return rt;
}
The if (order) guard is what makes it safe to call for phantom orders too.
Note order->last_fill_price = order->price — the order's own price, not the trade price. For a
resting order those are the same. For an aggressor they need not be, which is why
FifoMatchingEngine::match sets last_fill_price to the actual level price itself before calling
executeOrder. The two writes overlap; see
Known gaps.
Snapshot generation¶
Three virtual methods turn book state into md_queue_ frames. Only the L2 specialisations are
implemented.
| Method | Emits | Called by |
|---|---|---|
populateL2SubscriptionResponse(queue, channel) |
SUB_RESPONSE + BookSnapshot |
Adapters, on a first subscription |
populateL2Snapshot(queue) |
BOOK_SNAPSHOT + BookSnapshot |
Adapters, on routine full-book updates |
populateMDBookUpdate(MDBookUpdate&) |
Top-10-per-side into a caller-owned struct | Exchange::publishMDBookUpdate (currently unused) |
The snapshot layout is bids first, then asks, with num_bid and num_ask counts — consumers index
levels[0 .. num_bid) for bids and levels[num_bid .. num_bid+num_ask) for asks. Both publishers
rely on exactly that convention.
to_string() renders a 20-level ladder for debug logging. It calls std::advance(it, -20) on
rend() without checking the book has 20 ask levels, so it is only safe on a well-populated book.
Tests¶
tests/unit/order_book/test_orderbook_operations.cpp— add, modify, delete, execute.tests/unit/order_book/test_orderbook_lookup.cpp— the three lookup indices and their invariants.