Skip to content

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<fixed_string<37>, Order*, …>     orders_by_client_order_id_;
std::unordered_map<fixed_string<37>, Order*, …>     orders_by_order_id_;
std::unordered_map<price_t, qty_t>                  feed_md_level_quantity_;  // phantom qty per level

The two id indices are keyed by the order's own utils::fixed_string type rather than std::string, so inserting one does not allocate a copy of the key. They carry the transparent StringViewHash/StringViewEq functors, which take std::string_view — a fixed_string converts to one implicitly, so both the keys and the string_view lookups go through the same pair.

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:

// The level quantity excludes exchange orders
qty_t getMDLevelQty(price_t price) const;

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 fixed_string<37> Random Boost UUID in allocateOrder() The exchange order id the client sees
client_order_id fixed_string<37> Client-supplied Client-side correlation

Those two, plus symbol and user_id, live in an OrderIdentity base that Order derives from, laid out to match the header of an OrderResponse byte for byte. That is what lets setOrderIdentity() fill a response's four id fields with a single memcpy; messages.hpp static_asserts the layout, so resizing a field on either side fails the build rather than quietly shifting the ids on the wire. They behave like strings at the call site — assignment, .empty(), comparison, std::format — with one exception: LOG_* calls need .view(), because slick-logger dispatches on exact argument types and falls through to std::to_string otherwise. That is a compile error, not a silent wrong value.

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();

    static thread_local boost::uuids::random_generator uuid_gen;
    const boost::uuids::uuid u = uuid_gen();
    boost::uuids::to_chars(u, order->order_id.data());   // 36 chars, straight into the order
    order->order_id.data()[36] = '\0';

    order->id = utils::nextOrderId();
    order->created_time = utils::get_current_time_ns();
    order->last_update_time = order->created_time;
    order->resetFillAccounting();
    return order;
}

The generator is thread_local rather than constructed per call: building one seeds a Mersenne twister from the OS entropy source, which cost far more than the id it produced. Orders are allocated on the exchange thread, and thread_local keeps this correct if that ever stops holding. A rendered UUID is exactly 36 characters, which is the capacity of order_id, so it is written straight into the order's own buffer — a static_assert ties the two together.

The pool recycles memory, so allocateOrder hands back a previously used Order. resetFillAccounting() clears the fill state (cum_quantity, cum_value, avg_fill_price, last_fill_*, num_fills, fee, filled_value), and Exchange::handleNewOrderRequest then assigns symbol, side, type, TIF, price, quantity, leaves, status, client_order_id and user_id. Fields outside both sets — reject_message, cancel_message, edit_history — still carry over from whatever order last occupied the slot.

freeOrder returns an order to the pool. Symbol::addOrder calls it for anything that did not rest — fully filled orders, rejects and IOC remainders alike — modifyOrder when leaves_quantity == 0, and Symbol::cancelOrder unconditionally.

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->cum_value       += cum_value_t(order->price) * cum_value_t(executed_quantity);
        order->cum_quantity    += executed_quantity;
        order->avg_fill_price   = order->cum_value / order->cum_quantity;   // integer, see below
        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 a trade price passed in. That is correct here because this only ever runs against the resting side: executeOrder is called with the book order's id, and a resting order trades at its own price. An aggressor's fill price is the level it matched, which FifoMatchingEngine::match writes onto the aggressor directly; it is never routed through this function, because an order does not enter orders_ until after it has finished matching.

The fill accounting here mirrors the aggressor-side arithmetic in the matching engine, including the cum_value running notional that keeps avg_fill_price free of accumulated rounding error — see average fill price. The two copies have to stay in step.

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.