Skip to content

Market data

Market data flows in one direction: the real venue's WebSocket feed → an MDFeed → the exchange thread, which mutates the book → the internal binary md_queue_ → the venue's publisher → the client's WebSocket.

The inbound half is optional. With no feed configured the simulator runs self-contained: the first two stages below are simply absent, the book is driven purely by client orders, and everything from md_queue_ onwards works exactly as described here.

flowchart LR
    A["Real venue WS"] --> B["MDFeed<br/>(venue SDK thread)"]
    B --> C["Exchange callbacks<br/>queue events"]
    C --> D["Exchange thread<br/>apply to OrderBook"]
    D --> E["md_queue_<br/>binary frames"]
    E --> F["Publisher<br/>(uWS thread)"]
    F --> G["Client WS<br/>venue-native JSON"]

Feeds

MDFeed (md_feed.hpp) is a two-method interface — start() and stop(). Two implementations exist, both live; a historical replay feed would slot in at the same interface but does not exist yet.

CoinbaseLiveWSFeed

A thin wrapper over coinbase::WebSocketClient. On start() it subscribes the configured symbols to the LEVEL2 and MARKET_TRADES channels. Callbacks are delivered to the CoinbaseExchange (which implements coinbase::UserThreadWebsocketCallbacks) through a slick::stream_buffer_multiplexer sized by md_queue_size; the exchange thread drains it by calling processData().

Every feed also calls ws_client_->logData("logs/coinbase_data"), so a raw capture of the upstream WebSocket stream is written to disk whenever the Coinbase feed is running. This is unconditional and not configurable.

Config selects it by "type": "coinbase_live_ws", which merely sets use_live_feed_ = true — the actual feed objects are created lazily, one per symbol, in handleMdSubscription.

HyperliquidLiveWSFeed

Wraps hyperliquid::Info with user_thread_dispatch = true, meaning the SDK buffers messages rather than calling back on its own thread; the exchange thread pumps them with getInfo()->dispatch(100) inside drainEventQueue().

Per coin it opens two subscriptions:

info_->subscribe({{"type", "l2"},     {"c", coin}},     );   // undocumented compressed-diff channel
info_->subscribe({{"type", "trades"}, {"coin", coin}}, );

Note it subscribes upstream to l2, not the documented l2Book — the compressed diff channel is far less bandwidth. addCoin() allows subscribing more coins after start().

Config selects it by "type": "hyperliquid_live_ws", and unlike Coinbase the feed object is built in the constructor from the coins array, so those coins are subscribed at startup.

Any other type value is silently ignored. The Alpaca historical feed does not exist.

Coinbase: time-ordered event sequencing

Coinbase delivers l2_data and market_trades on separate channels with independent latency, so messages can arrive out of order relative to their exchange timestamps. Applying them in arrival order would corrupt the book. CoinbaseExchange therefore buffers and re-orders.

Each symbol gets a SymbolEventState:

struct SymbolEventState {
    std::priority_queue<Event, std::vector<Event>, EventCompare> pending_events;
    uint64_t last_event_time     = 0;   // newest event_time seen
    uint64_t last_sequence_id    = 0;
    uint64_t last_seq_num        = 0;
    uint64_t next_sequence_id    = 0;   // monotonic tiebreaker
    uint64_t last_published_level_seq_num = 0;
    uint64_t last_published_trade_seq_num = 0;
};

EventCompare orders by event_time ascending, tie-broken by the internal sequence_id so events with identical exchange timestamps keep arrival order.

The WS callbacks (onLevel2Updates, onMarketTrades) only push into this queue — they never touch the book. Release happens on the exchange thread in processSequencedEvents(), which pops an event only when one of two conditions holds:

// 1. a full second of newer data has arrived, so nothing older can still be in flight
bool safe_by_time_gap = last_event_time > 0 &&
                        top.event_time + ONE_SECOND_NS <= state.last_event_time;
// 2. the event has waited a full second regardless
bool safe_by_timeout  = (now - top.received_time) >= ONE_SECOND_NS;

This buys ordering at the cost of a one-second delay. Book updates are held for at least a second before being applied, so the simulated book lags the real market by that much. processSequencedEvents(true) forces an immediate flush but is never called with force = true in the current code.

dispatchEvent then applies the event. For a level update it either creates a brand-new level (matching against the opposite side first, in case the new quote crosses a resting simulator order) or adjusts an existing one via the getMDLevelQty delta logic described in Order book. Stale events — event_time older than book.lastUpdateTime() — are logged and dropped.

Level and trade updates are batched into level_update_buffer_ / trade_update_buffer_ and flushed to md_queue_ when the sequence number changes, so one upstream batch becomes one downstream message.

Snapshots take a different route entirely: onLevel2Snapshot runs on the WS thread and mutates the book directly rather than going through the priority queue.

Hyperliquid: L2 diffs and index resolution

Hyperliquid's undocumented l2 channel sends compressed incremental diffs:

{ "c": "ETH",
  "l": [ [ {"p": "3000.5", "s": "12.0"} ], [ {"p": "3001.0", "s": "8.5"} ] ],
  "r": [ [2, 5], [0] ],
  "t": 1723300000000 }
  • l — changed or added levels as absolute new quantities, [bids, asks].
  • r — levels removed entirely, given as indices into the previous per-side ordered price array, not as prices.
  • t — event time in milliseconds.

Those indices are the problem: to turn index 2 back into a price, you need the exact ordered price list the venue had before this event. The simulator's own book cannot supply it, because it also contains your orders and may have levels the real market does not.

So HyperliquidExchange keeps a separate mirror:

// Symbol* -> (bid prices, ask prices), best-first, real-market only
std::unordered_map<Symbol*, std::array<std::vector<price_t>, 2>> l2_ref_levels_;

It is rebuilt from every snapshot (which arrives best-first, so appending preserves order) and maintained across diffs with std::lower_bound insertion using the side-aware utils::isPriceBetter comparator.

processL2Diff resolves all removal indices against the pre-mutation array before applying any of them — removing while iterating would shift the later indices. Out-of-range indices are logged and skipped rather than treated as fatal.

Removals and changes both funnel into applyPhantomLevelUpdate(symbol, side, price, target_qty, ts), which sets phantom liquidity at one price to exactly target_qty:

  • New level — match against the opposite side first (this price has never been checked for crossing), then rest whatever survives.
  • Existing level — already known non-crossing, so skip matching and just adjust the phantom-only quantity toward the target, shrinking from the back and never touching simulator orders.

A removal is simply target_qty = 0.

Snapshots (processL2Snapshot) call clearMDOrders() — preserving resting simulator orders — then rebuild every level and reset the mirror.

The internal market-data wire format

md_queue_ is a slick::queue<uint8_t> carrying variable-length frames. All structs are #pragma pack(1) and use the data[0] flexible-array-member idiom, so a frame is a header immediately followed by its payload in the same contiguous reservation.

Producing a frame

auto sz    = sizeof(MarketDataUpdate) + sizeof(MDLevelUpdate) + n * sizeof(MDLevel);
auto index = md_queue_.reserve(sz);
auto* update = reinterpret_cast<MarketDataUpdate*>(md_queue_[index]);
update->type  = MDUpdateType::LEVEL;
update->venue = venue_;
std::memcpy(update->symbol, symbol, sizeof(update->symbol));
auto* payload = reinterpret_cast<MDLevelUpdate*>(update->data);
// … fill payload …
md_queue_.publish(index, sz);

Consumers do the mirror: read(cursor), cast to MarketDataUpdate*, switch on type, cast update->data to the matching payload type.

Frame header

MarketDataUpdate — 34 bytes:

Offset Size Field Notes
0 32 char symbol[32] Not guaranteed NUL-terminated for 32-char symbols
32 1 Venue venue
33 1 MDUpdateType type Selects the payload type
34 uint8_t data[0] Payload begins here

Payload by MDUpdateType

type Payload Produced by Consumed by
BOOK (0) MDBookUpdate publishMDBookUpdate — never called Coinbase publisher ignores it
LEVEL (1) MDLevelUpdate publishLevelUpdate Coinbase → l2_data "update"
ORDER (2) MDOrderUpdate publishMDOrderUpdatenever called
TRADE_SUMMARY (3) TradeSummary publishTradeSummary Neither publisher handles it
TRADE (4) MDTradeUpdate publishMDTrades Hyperliquid → trades; Coinbase drops it
BOOK_SNAPSHOT (5) BookSnapshot populateL2Snapshot Coinbase → l2_data "snapshot"; Hyperliquid → l2Book / l2 diff
ORDER_SNAPSHOT (6) Never produced
SUB_RESPONSE (7) MDSubscriptionResponse + nested payload populateL2SubscriptionResponse, populateMDTradesResponse, rejectMdSubscription Both publishers, to deliver the first snapshot

Payload structs

struct MDLevel {              // 41 bytes
    uint64_t event_time;      //  0  ns
    uint64_t seq_num;         //  8
    price_t  price;           // 16  fixed-point ×1e8
    qty_t    qty;             // 24  fixed-point ×1e8
    uint32_t num_orders;      // 32
    uint16_t level_index;     // 36
    MDUpdateAction update_action;  // 38  NEW / CHANGE / DELETE
    uint8_t  flags;           // 39  UpdateFlags bitmask
    Side     side;            // 40
};

struct MDLevelUpdate { uint64_t event_time; uint32_t num_level_update; MDLevel levels[0]; };
struct BookSnapshot  { uint32_t num_bid; uint32_t num_ask; MDLevel levels[0]; };  // bids then asks

struct MDTrade {              // 34 bytes
    uint64_t event_time; uint64_t seq_num; price_t price; qty_t qty;
    UpdateFlags flags; Side side; };
struct MDTradeUpdate { uint32_t num_trades; MDTrade trades[0]; };

struct MDOrder {              // 41 bytes
    uint64_t order_id; uint64_t event_time; uint64_t priority;
    price_t price; qty_t qty; Side side; };
struct MDOrderUpdate { uint32_t num_orders; MDOrder orders[0]; };

struct MDSubscriptionResponse { uint8_t channel; MDSubscriptionRejectReason reject_reason;
                                uint8_t data[0]; };

struct TradeSummary { time_t timestamp; uint64_t trade_id; int16_t security_id;
                      Side aggressor_side; price_t price; qty_t qty;
                      int32_t num_orders; Trade trades[0]; };
struct Trade { uint64_t order_id; qty_t qty; };

MDBookUpdate is the one fixed-size payload: MDLevel sell[10], MDLevel buy[10], and uint8_t update_index[2] marking which level changed per side.

price_t width

price_t and qty_t are int_fast64_t, whose size is implementation-defined — 8 bytes on x86-64 Linux and Windows, which is what these layouts assume. Because the queue may be backed by shared memory, a reader built with a different int_fast64_t width would misparse every frame.

UpdateFlags

enum UpdateFlags : uint8_t {
    F_NONE        = 0,
    F_IS_SNAPSHOT = 1,        // part of a snapshot rather than an incremental update
    F_END_EVENT   = 1 << 1,   // last message of a batch
};

F_END_EVENT maps to slick-orderbook's ChangeFlag::LastInBatch, set in Symbol::onPriceLevelUpdate.

MDSubscriptionRejectReason

NONE (0), UNKNOWN_CONTRACT (1), MARKET_CLOSED (2). Exchange::rejectMdSubscription exists and builds a well-formed frame — but marks the reason parameter [[maybe_unused]] and never writes it into rsp->reject_reason, so the field is whatever the recycled queue slot held. Nothing calls the function anyway.

Publishing

Each publisher runs a uWS event loop and drains md_queue_ from a private cursor (WebsocketMarketDataPublisher::publish_processing), dispatching each frame to the venue-specific publish_market_data_update. Client subscriptions are tracked per channel and per symbol, so a frame for an unsubscribed symbol is dropped cheaply.

The wire formats produced are documented per venue:

Subscriptions travel in the opposite direction: a client's subscribe message causes the publisher to write a MD_SUBSCRIPTION Request onto request_queue_, which the exchange thread handles by creating the symbol (if new), attaching a feed, and marking the symbol pending until the first snapshot arrives. Unsubscribes are written the same way but never processed.