- Out-of-tree venues.
-DSLICK_SIM_EXTERNAL_VENUES=/path/to/venue(;-separated for several) builds venue directories kept in their own repositories intoslick-sim. Each one callsslick_sim_add_venue()exactly like an in-tree venue, and no file in slick-sim changes. Configure prints every directory as it is added and the venue names it registered, and warns about a directory that registered none. Seedocs/extending.md. slick_sim_add_venue()takes aTESTSlist. The sources are compiled intoslick_sim_tests, so a venue's suite travels with the venue and an external one needs no edit totests/CMakeLists.txt.- Order-by-order market data.
Exchange::flushMarketDataCachespublishes the book's per-order changes asMDUpdateType::ORDERframes (MDOrderUpdate) for an adapter that setspublish_order_updates_. They were collected for every venue and then discarded behind aTODO. The frame is published last, after the event'sTRADE_SUMMARYandLEVELframes, so for such a venue it marks the end of an event.MDOrder::update_actionsays whether the order entered the book, changed, or left it. Off by default, and no in-tree adapter turns it on: Coinbase and Hyperliquid clients still receive level data only. - One venue config entry can create several exchanges.
ExchangeRegistry::Factoryreturns a vector, and an adapter that declaresstatic create_instances(const nlohmann::json &)decides how many instances its entry describes, for a venue split into independent market segments. Every other adapter still yields exactly one. Request::request_tag, an opaque correlation token a gateway sets on a request and gets back on everyOrderResponsethat request produces. A passive fill carries the tag of the last request the order accepted, and a rejected amendment does not replace it.OrderResponse::order_num, the order's numeric id. It is the same number the order carries in order-by-order market data, so a client can find its own orders in that feed.0on a reject of a new order.OrderResponse::aggressor, set on a fill for the order that crossed the spread.SLICK_SIM_ENABLE_FIX_JSON_PARSERS(defaultOFF) builds the generic FIX and JSON message parsers as their ownslick_sim_message_parserstarget.TcpOrderGateway::listen_port()returns the bound port, which is the one the OS assigned when the configured port is0.- Test helpers
makeTestQueue<T>(size, name)andmakeTestExchange<T>(config)intests/unit/test_helpers.hpp, for a fixture that names a shared-memory segment. See Fixed.
- The shared order-priority sequence is drawn atomically. Every exchange runs its books on its own thread and all of them share one counter, so a plain increment could hand a book a priority lower than one it had already issued, and its FIFO order would run backwards.
- A venue that rejects its config by throwing now stops startup with an error naming the
exchangeskey, instead of terminating the process on an uncaught exception. - Unit tests no longer read each other's shared-memory queues on Linux and macOS. slick-queue 2.3.0
stopped unlinking a named segment in the queue's destructor, so every fixture that named one left
it in
/dev/shm, and the next test attached to it and read the previous test's responses. Those fixtures build their queues and exchanges with the new test helpers, which unlink the segments when the test ends and clear any a crashed run left behind. - Configure no longer fails when slick-logger is built from source.
slick_sim_corelinksslick::queueby name but relied on slick-logger's package config to bring it in, which does not happen on the FetchContent path. slick-queue is now resolved explicitly at the top level. - Configure no longer fails when an installed slick-queue is older than the required version. The
fetched copy and the installed package were both picked up, and the installed config failed with
cannot create ALIAS target "slick::slick-queue". The fetch is declared withOVERRIDE_FIND_PACKAGE, so every laterfind_package(slick-queue)gets the fetched copy.
TcpOrderGatewayis rebuilt as a transport base for venues on a binary session protocol. It runs the slick-socket server thread, keeps the unconsumed tail of each connection's stream so a protocol only ever sees bytes starting at a message boundary, and drainsresponse_queue_on the server thread so a protocol can answer fromon_responsewithsend(). A venue derives from it and implementson_dataandon_response; framing, sessions and sequencing are the venue's. The constructor no longer takes aProtocolType, and the per-client parser API is gone:set_order_callback,send_execution_report,set_client_protocol,enable_auto_protocol_detectionandget_client_protocols. A derived class must callstop()in its own destructor.- The FIX and JSON parsers are no longer part of
order_gateway, andorder_gateway.hppno longer includesmessage_parser.hpp. QuickFIX is needed only withSLICK_SIM_ENABLE_FIX_JSON_PARSERS, andSLICK_SIM_ENABLE_TCP_GATEWAYnow needs slick-socket and nothing else. Request,OrderResponse,MDOrderandOrdergained fields, so their layouts changed. A process that reads the queues over shared memory must be rebuilt against this version.SLICK_SIM_REGISTER_VENUEregisters a factory that returnsstd::vector<std::unique_ptr<Exchange>>. An adapter that uses the macro needs no change; code that calls a registry factory directly does.MatchingEngine::publishOrderExecutiontakes a second argument, whether the order was the aggressor.OrderBook::nextOrderPriority()is public, so a feed can draw a market order's place in the queue when the order joins and re-add it under the same priority later.- A named shared-memory queue is no longer removed at shutdown. This follows slick-queue 2.3.0.
On Linux and macOS the segment stays in
/dev/shm, and a simulator restarted with the same*_shm_nameattaches to the old segment and whatever is still in it. Seedocs/configuration.md. - Dependencies: slick-queue 2.3.0 (new as a direct requirement), slick-logger 1.1.4 to 2.2.1,
slick-object-pool 0.1.3 to 0.2.0, slick-orderbook 1.0.5 to 1.1.0, slick-socket 1.0.6 to 1.2.0,
coinbase-advanced-cpp 1.0.0 to 2.1.0 and hyperliquid-cpp 0.3.0 to 0.4.1. The Coinbase SDK is fetched
at a release tag instead of
main. slick headers are included as.hpp; the.hspellings are deprecated upstream. cmake/SlickSimVenue.cmakeis renamedcmake/slick_sim_venue.cmake.- The sample config carries an explicit
"enabled"key on each venue and on the Coinbase feed. It ships with Coinbase enabled and its live feed off, and with Hyperliquid disabled.
SbeParser(src/order_gateway/sbe_parser.hpp,.cpp). SBE is a family of venue schemas, each with its own session layer, so a venue adapter decodes it.MessageParserFactoryreturnsnullptrforProtocolType::SBE.
- Trade updates on both venues' market-data channels. Coinbase
market_tradesand Hyperliquidtradesnow deliver a recent-trades snapshot on subscribe followed by a message per executed trade. Previously a subscriber got an empty snapshot and then silence: the Coinbase publisher had no case for trades at all, and neither publisher handledMDUpdateType::TRADE_SUMMARY, so every fill the matching engine produced was written tomd_queue_and dropped. - A venue's own trade print is replayed through the matching engine as an incoming aggressor order instead of being relayed verbatim, so it takes liquidity exactly as the real trade did and fills any simulator order ahead of the phantom liquidity it sweeps. Only the resulting fills are published; anything the print cannot fill rests on the aggressor's side at the print price.
MDTrade::trade_id, an independent sequence per venue, so a live print and the same trade replayed in a snapshot carry the same id for every client. When shadowing a live feed, Coinbase's own ids are preserved and the simulator numbers from above the highest seen.- The subscribe-time trade snapshot is split against the book snapshot the subscriber receives
(
MDTradeSnapshotResponse): trades that book does not reflect are delivered as a normal update rather than as settled history, keyed on the newUpdateFlags::F_NOT_IN_BOOK.order_book_->lastUpdateTime()cannot draw the boundary because it tracks level updates only, and the flag is not monotonic over the trade history, so the response is built by partitioning into two contiguous blocks — copying in history order would interleave them while the counts said otherwise. HyperliquidExchangeanswerstradessubscriptions. It handled onlyl2Book/l2, so atradessubscriber never received a SUB_RESPONSE, and therefore never the subscription confirmation that the publisher only emits on one.- Self-match prevention is reachable from a running simulator. It was implemented and unit tested but
inert: nothing outside the tests assigned
Order::client_id,Symbol::smp_mode_had no configuration key, andSymbol::modifyOrderdropped the mode. Aself_match_preventionkey ("none"|"cancel_resting"|"cancel_newest") on an exchange now applies to every symbol it creates and to both order paths, withExchange::clientIdForderiving the identity from the authenticateduser_id. Orders with nouser_idstay exempt, including from each other. utils::fixed_string<N>(src/utils/fixed_string.hpp): a null-terminated buffer stored inline, laid out as exactlychar[N]so a run of them matches a run of fixed-width wire fields byte for byte.utils::copy_wire_field(dst_array, src_view)replaces every hand-written wire-field copy in the tree. When logging one, pass.view()— slick-logger dispatches on exact argument types and falls through tostd::to_string, so passing afixed_stringis a compile error.std::formatandoperator<<work directly.order_gateway::PendingResponses(src/order_gateway/pending_responses.hpp): correlates an in-flight HTTP request with the execution report that answers it, so a REST handler can register what it waits for and return instead of polling — the first half of removing the blocking waits in known gaps. Nothing calls it yet: the uWebSockets side (holding anHttpResponsepast handler return, the abort path, the per-request timer) is its own change with an integration test. Waiters match by predicate rather than a keyed lookup because handlers correlate on different things — a create knows itsclient_order_id, a cancel only the timestamp it stamped. A timer that fires after its report arrived is a no-op and an abandoned waiter is never completed, so neither can answer a request twice or write to a closed socket.order_gateway::OrderStore(src/order_gateway/order_store.hpp): a gateway-side projection of order state folded from the execution-report stream, so a gateway can answer "list this user's orders" without touching the order books the exchange thread owns. One instance per gateway with its own queue cursor, so it never disturbs handlers polling the same queue, and no locking, since a gateway touches it only from its own event loop. A store constructed with a queue drains it on demand (the REST gateway, which has no continuous reader); a default-constructed one is fed throughapply()by an owner already reading the stream (the WebSocket gateway).- MD feed enable/disable config support.
-
A cancel publishes the level update it produces.
Symbol::cancelOrderparks the shrunk level inmd_level_update_cache_like every other book mutation, and the caches are drained only at the end of a request handler — buthandleCancelOrderRequestwas the one handler that never drained them. A cancel's delta therefore went out only if a later add or modify happened to flush it, and not at all when the cancel was the last request, leaving every subscriber's incrementally-maintained book showing quantity at a price where nothing rested any more. Withdrawing a resting quote is exactly that shape, so a market maker pulling the tail of its ladder left phantom levels on every client's book indefinitely. The drain is nowExchange::flushMarketDataCaches(), called from all three order handlers and from the Coinbase feed paths that had the block copy-pasted. -
split_book_snapshot()no longer binds references to underalignedMDLevelmembers.MDLevelis byte-packed (41 bytes), so only the first level of a frame is naturally aligned; passinglevel.num_ordersinto nlohmann's converting constructor andlevel.price/level.qtyintoemplace_backboundconst T&to members at odd offsets, which is UB and failed the UBSan CI job on every snapshot carrying more than one level. The members are loaded into locals first — reading a packed member by value is well-defined. The packing hazard is now documented at the#pragma packinmarket_data.hpp. -
Hyperliquid prices and sizes go out with the venue's trailing fractional digit: a whole
77052renders as"77052.0", not"77052". The sharedto_fixed_stringtrims a whole number down to the integer, which is right for Coinbase but not for Hyperliquid, whose serialiser always leaves one digit behind — across a session capture all 201 distinct prices ended in".0", and not one of 8807 distinct sizes carried a trailing zero. Nothing is padded past that floor. Only a client keying levels by the received price string rather than a parsed number can tell, and that client broke on it.to_fixed_stringgained amin_frac_digitsfloor, defaulting to the old behaviour;to_hyperliquid_number()applies it for that venue'sl2Book,l2andtradesencoders. -
The Hyperliquid publisher truncates
l2Bookandl2to the 20 levels a side the live venue publishes. It served the simulator's whole book instead, which is not depth-limited — it holds every level the feed ever named plus anything a trade print left resting — so subscribers saw a deeper book than Hyperliquid ever sends.split_book_snapshot()now builds the wire JSON and thel2diff baseline from the same truncation, so removal indices keep addressing the array the subscriber actually holds. -
The Hyperliquid publisher emits
l2removal indices (r) in the live venue's own order: ascending price, so bid indices descend and ask indices ascend. They went out in ascending index order on both sides. A client that splices each index off as it reads it — correct against the venue, because Hyperliquid's bid removals descend and no deletion then moves an entry a later index still refers to — desynced on the first multi-index bid removal, dropping the wrong level and keeping one the venue had retired. The kept level went on resting above the best ask, and every later index-based removal on that side landed one slot off.processL2Diffwas already correct on the way in, resolving all indices against the pre-mutation array; only the outbound encoder was wrong. -
Self-match prevention under
CANCEL_NEWESTis decided before the order is acked, mutated or reported on, so a rejected order leaves no trace. The check ran inside the matching loop, after a new order was acked and after an amendment had written its new price and quantity onto the stored order and reportedREPLACED— leaving a rejected amendment half-applied against a book that never moved, and the client holdingREPLACED,CANCELEDandREJECTEDfor one request. There is now a single terminal response, and a fill-or-kill order blocked by SMP reportsSMPrather thanFOK_CANNOT_FILL, since the liquidity existed. -
The
CANCEL_NEWESTpre-check scans only as deep as the order will actually trade. It used total quantity, which for a partially filled amendment exceeds its remaining leaves, so it could reach one of the owner's resting orders the amendment would never touch and reject a fill that should have happened. -
CANCEL_RESTINGpublishesCANCELEDto the resting order's owner and returns theOrderto the pool, instead of only removing it from the book — which left the client believing its order was live and leaked the allocation. -
Orders that do not rest are returned to the pool.
Symbol::addOrderfreed only fully filled orders, leaking every reject (SMP, FOK) and every IOC remainder, andOrderBook::executeOrderdropped a fully filled resting order from its three lookup maps without callingfreeOrder, leaking every completed passive fill.slick::ObjectPoolfalls back to heap allocation once exhausted, so both leaks degraded silently instead of failing. -
The resting side of a trade gets an execution report.
match()published for the aggressor and then calledbook.executeOrder(), which books the fill but cannot publish —OrderBookholds no reference to the matching engine — so the resting client saw its order disappear from the book with noTRADEreport, fill price or quantity. Only the aggressor path was affected; the market-data replay overload always reported both sides, as didCANCEL_RESTING. -
An amendment recomputes
leaves_quantityfromcum_quantityinstead of adjusting it by the quantity delta, so it cannot drift from the fill state. An order amended to at or below what it has already filled settles at zero leaves and is cancelled, rather than relying on a negative-value guard after the fact. -
rejectModifyOrderRequestandrejectCancelOrderRequestread the correct union member ofRequest. Both readadd_order, so the reject carried the order id in itsclient_order_idfield and read price and quantity from the wrong offsets — neither of which a cancel even has. -
The market-data replay overload of
match()marks the end of a batch.is_last_in_batchwas passed asqty == 0but read before the fill was subtracted, and the loop only runs whileqty > 0, so it was permanently false: the level update carrying the final fill of a replayed print never setUpdateFlags::F_END_EVENT, and a client batching until that marker never saw its batch close. The order-driven overload already subtracted first; the two now agree. -
A venue trade and the level update reflecting it no longer reduce the same price level twice. The quantity a trade removes from phantom liquidity is credited per price level and per trade timestamp, so a level update landing between two trades at the same price offsets by the later one alone.
CoinbaseExchange::EventComparealso breaks timestamp ties by applying trades ahead of level updates. -
Coinbase
l2_datadeltas carry the level'stotal_quantityas the book actually holds it, not the venue's rawevent.qty. The book may decline an update at face value (a stale one whose targetreconcilePhantomQtylowered, or a new level that rested only what survived matching), and the raw figure omitted the simulator's own resting orders, which a real venue's L2 would show. Deltas now share one basis withSymbol::onPriceLevelUpdateandpopulateL2Snapshot, so a re-read snapshot is consistent with the deltas a client has been applying. -
HyperliquidExchangeno longer discards the trade summaries returned bymatch()inprocessL2SnapshotandapplyPhantomLevelUpdate. Fills caused by feed-driven level crossings were invisible on the public tape, even though the client's own fill report was sent. -
CoinbaseExchange::onMarketTradesSnapshotno longer writes past the end of its queue reservation: a second loop wrote anothersnapshots.size()trades through the already-advanced pointer. Reached whenever amarket_tradessubscription was pending and a trades snapshot arrived. -
TradeSummary::security_idis populated. It was never written, so consumers read uninitialised queue memory. -
MDTrade::event_time,seq_numandflagsare set on every trade written to a subscription snapshot. Two of the three Coinbase producers left them as whatever the recycled queue slot held. -
MDSubscriptionResponse::reject_reasonis written on every path and checked before the payload behind it is read. Three gaps lined up:rejectMdSubscriptionmarked the reason[[maybe_unused]], the L2 accept path set onlychannel, and both publishers castresponse->datato aBookSnapshotwithout consulting the field — but a reject reserves room for the response header alone, so a publisher reaching past it readsnum_bid/num_askfrom beyond the frame and walks that many levels (Hyperliquid alsoreserve()s on those counts). Latent rather than live: nothing callsrejectMdSubscriptionyet. Publishers now report the rejection to the channel's subscribers via the venue's own error message, andto_string(MDSubscriptionRejectReason)names it. -
Every publish path read past the end of the name it was copying.
memcpy(dst, name.c_str(), sizeof(dst))copies the full width of the wire field out of a buffer holding only the name's own characters — 32 bytes read from a 7-character"BTC-USD". It appeared onOrder's four identity fields (once per field, four times per response, in all seven response builders), onSymbol::symbol_inpublishTradeSummary,publishTradeSubscriptionResponse,publishLevelUpdateandpublishMDOrderUpdate, onOrderBook::symbol_in bothpopulateL2*methods, and on both publishers' subscribe and unsubscribe paths. Names are now held at their wire width, so the copy is in bounds by construction. -
On the publishers' subscribe/unsubscribe paths that over-read also skipped the zero-fill: whatever followed the name in memory landed in
Request::symbol, which the exchange thread reads as a C string, so one non-zero byte there changed the symbol being subscribed to. It worked only because a shortstd::string's inline buffer happens to be zero-padded. -
The REST gateways left wire fields unterminated. Both used
memcpy(dst, s.c_str(), std::min(sizeof(dst), s.size())), which is bounded but clamps one byte too late: a value that exactly fills the field overwrites the last zero the precedingmemsetwrote, and the reader runs past the end. It affectedsymbol,user_idandorder_idon Coinbase andsymbolon Hyperliquid; only Coinbase'sclient_order_idhad a hand-written guard. All 24 such copies now go throughutils::copy_wire_field, which truncates to N-1, zero-fills the remainder and reports truncation so the existingclient_order_idwarning still fires. -
to_price_t/to_qty_tround to the nearest tick instead of truncating.value * 1e8is a binary floating-point multiply and most decimal fractions have no exact binary form, so the product landed just below the intended integer and the cast threw the remainder away: 8.2 became 819999999, a price of 8.19999999. 0.29 and 4.35 went the same way, on prices and quantities alike, for every value parsed off a venue feed or a client order. -
Fixed-point values render onto the wire straight from the integer, through the new
to_price_string/to_qty_string, instead ofstd::to_string(double)— which formats with%f, exactly six fractional digits against a scale of eight, so a size of 0.00011628 was published as "0.000116" and a single tick as "0.000000", which a receiver reads as zero. All 41 outbound sites across the publishers, encoders and REST/WS gateways use the exact form; genuine doubles (fees, notionals, completion percentages) are unchanged. Whole numbers render without a decimal point and trailing fractional zeros are trimmed, so 99.0 is "99" and 8.2 is "8.2". -
The Coinbase WebSocket
userchannel sends the subscriber its real orders. The snapshot readorders_by_client_id_, which was declared but never written, so it was always empty whatever the client had traded. That container and two more beside it (orders_,order_fills_) were dead and are gone, replaced by the sharedOrderStore— fed fromprocess_all_responses(), which already reads every execution report, so the gateway takes no second pass over the queue. -
Coinbase
GET /api/v3/brokerage/orders/historical/batchanswers. The authenticated path built a response body and returned without callingend(), so the request was never completed and the client blocked until its own timeout; only the 401 path replied. It also had nothing to report — the endpoint was a stub whose orders array was always empty. -
Both REST gateways' create-order handlers capture the response cursor before publishing the request.
slick::queue::initial_reading_index()returns the queue's current write cursor, and the exchange thread is a spin loop, so it could consume the request and publishPENDING_NEWin the gap — leaving the handler polling from past its own response until it returned a 504 for an order that had actually been accepted, and might already have filled. The cancel and modify handlers already did this. -
Both heartbeat timers wrote their
thispointer past the end of the timer allocation.us_create_timer(loop, fallthrough, ext_size)takes the extension size as its third argument, and both call sites passed0before storing a pointer atus_timer_ext(timer)— an out-of-bounds heap write ofsizeof(void*)bytes, read back on every ping. It affectedWebsocketMarketDataPublisher(so every venue's market-data publisher) andCoinbaseWebsocketOrderGateway; both now requestsizeof(T*). -
HyperliquidPublisherre-declared nine members that already exist inWebsocketMarketDataPublisher(ws_thread_,listen_socket_,loop_,heartbeat_timer_,port_,running_,data_cursor_,ping_interval_ms_,pong_timeout_), shadowing the base copies that the inheritedstart()/stop()actually drive;check_heartbeats()read the shadowedpong_timeout_.publish_market_data_updatealso gained a missingoverride. -
SymbolManagerhanded outSymbol*that went stale. It owned its instruments in astd::vectorreserved for 512 while storing raw pointers to them in its lookup map, so creating the 513th relocated every element and dangled the map's entries, the pointers exchanges had stored at subscription time, and the return value of the create that triggered it, all at once. Storage is now astd::deque, which never moves an element it already holds. -
SymbolManager's lookup was keyed on the instrument name alone, so the same ticker on two venues collided: the secondcreateSymbolfound the name present and itsSymbolnever became reachable, so every lookup returned the other venue's instrument, routing orders and market data to the wrong book. The map is keyed by(Venue, name)now andgetSymboltakes the venue; re-creating an existing instrument returns it rather than stranding a duplicate. -
Symbol's move constructor and move assignment movemd_level_update_cache_andmd_order_update_cache_instead of dropping them.SymbolManager::createSymbolbuilds aSymbolon the stack and moves it into its container, so every symbol in the process ran with zero reserved capacity and both caches reallocated from scratch as market data arrived. -
Exchange::stop()stops the market-data feeds — the base class knew only the singularmd_feed_while both adapters kept their ownmd_feeds_vector — and joins the exchange thread before touching them, sincepoll()andhandleMdUnsubscriptionboth access that vector on it. It is also idempotent now, becausemain()stops each exchange and~Exchange()stops it again, which was harmless only while every step was individually guarded, and stopping the feeds is not:CoinbaseLiveWSFeed::stop()unsubscribes upstream. It no longer dereferencesmd_publisher_unconditionally either, despitestart()null-checking it. -
Boost::multiprecisionis linked rather than relying on Boost being on the default include path. -
order_gateway'sPUBLICinclude directories, which every other target silently depended on to resolve<common/...>, are replaced by a deliberateslick_sim_coreINTERFACE target. -
BUILD_COINBASE_ADVANCED_TESTS=OFFmoved from the top-level slick-orderbook block, where it ran after the Coinbase SDK had already been populated and so never reached it, into the Coinbase FetchContent path it names. -
Every live Coinbase market-data feed gets its own block of multiplexer producer ids. The feeds share
CoinbaseExchange::ws_mux_but each took the defaultproducer_offsetof 0, so the second feed'sWebSocketClientclaimed producer ids that the first had already registered, andstream_buffer_multiplexer::add_producerthrowsstd::invalid_argumenton a duplicate id — uncaught on the exchange thread, so subscribing a second symbol to a live feed terminated the process.handleMdSubscriptionnow passessymbol->id_ * coinbase::ProducerType::_PRODUCER_TYPE_COUNT_, reserving one id per producer type (market/user data and their control streams) per symbol, andCoinbaseLiveWSFeedforwards the offset to the SDK client along with the two default endpoint URLs it has to name to reach that argument. -
The Coinbase book is stamped with dispatch order rather than the venue's
sequence_num. EveryOrderBookL3mutator discards a call whose sequence regresses and says so only through aboolreturn that nothing checked, and the event queue reorders intoevent_timeorder — a different order from the arrival order that sequence counts. Coinbase's l2event_timelags its message timestamp by 100–200 ms while a trade's is near-live, so a trade routinely dispatched ahead of an l2 batch that arrived before it and sank the whole batch, every update in one sharing asequence_num. A recorded session lost 38.6% of level updates and 16.1% of trade replays that way: levels never retired (a bid left resting 35 dollars above the market ten minutes after the venue removed it) and the matching loop re-filled the same phantom order 98 times in 217 ms, publishing a fabricated trade print each pass.processSequencedEventsnow handsdispatchEventa per-symbol counter that is monotonic in dispatch order by construction;event.seq_numstays the venue's number forMDLevel,setLastUpdateand the publish batching.onLevel2Snapshotdraws from the same counter — it bypasses the queue, andclear()does not reset the book's sequence. An enqueue-time counter cannot serve:sequence_idregresses harder still, because the tie-break pops the highest first and 90% of events share an(event_time, type). -
Phantom liquidity is rested through
OrderBook::addBookOrder()rather than the inheritedOrderBookL3::addOrder(). That one takesprioritysixth, so the seven-argument calls indispatchEventandonLevel2Snapshotwere passing the sequence as a priority andflags & F_END_EVENT— 0 or 2 — as the sequence, which pinned the book's sequence at 2 and gave every phantom order a priority from the venue's counter instead ofnextOrderPriority().addBookOrdernow takesis_last_in_batchso the end-of-event flag survives the move. -
Coinbase's
level_update_buffer_moved intoSymbolEventState, one per symbol. It is flushed only when a later event carries a different sequence, so a single shared buffer still held one symbol's rows whenprocessSequencedEventsmoved on to the next symbol's queue — and the flush publishes under whichever symbol is dispatching. Rows went out under the wrong instrument, quoting prices belonging to another one, while the symbol that produced them never published them at all. It contaminated both directions and needed only two subscribed symbols. -
A Coinbase l2 snapshot discards the level-update batch in flight.
level_update_buffer_is only flushed downstream when a later event carries a different sequence, so rows buffered before a snapshot were published after the snapshot frame as an ordinary incremental update, telling a client to restore liquidity the reset had removed. The snapshot's own levels are no longer buffered either:populateL2Snapshot()already publishes the rebuilt book in full, so those rows only duplicated it, arrived whenever the next event happened to change sequence, and carried alevel_indexread while the rebuild was still in progress. The publish boundary (last_published_level_seq_num) is re-primed so the first event after a snapshot starts a batch as it would on a fresh subscription. -
A Coinbase l2 snapshot rebuilds through
OrderBook::clearMDOrders()rather thanOrderBookL3::clear()— the contract that method documents, and what Hyperliquid's snapshot path already called.clear()unlinks orders without notifying observers, sofeed_md_level_quantity_kept every pre-snapshot price and the rebuild added the snapshot's quantities on top of stale ones, leavingreconcilePhantomQtymeasuring every later update against a level quantity the book never held. It also wiped the simulator's own resting orders out of the L3 book whileorders_went on holding them, so after a reconnect a client's live order was absent from the book — unable to fill or be swept by a trade — yet still reported as resting. -
Exchange::reconcilePhantomQtychooses every reduction before applying any of them. It walkedlevel->ordersin reverse and reduced as it went, butmodifyOrder(..., 0)delegates todeleteOrder, which unlinks the order from the very intrusive list being walked — so advancing past it was undefined, and in practice the walk ended there. Only the level's last phantom order was ever removed; everything ahead of it stayed resting, and no later update could clear it, because reconcile then measured a level quantity that already agreed with the venue's. A level the venue had retired outright kept whatever its earlier orders held and sat at a price the market had left, crossing the other side as it moved away. Affects Hyperliquid equally — the helper is shared. -
A dropped Coinbase market-data connection restarts the feed it had rather than building a replacement.
WebSocketClientregisters its producers on the shared multiplexer from its constructor andstream_buffer_multiplexercannot unregister them, so the replacement threwstd::invalid_argumenton the first duplicate id, uncaught on the exchange thread. Handing it a fresh offset would only have traded that for a leak — the multiplexer owns each producer's stream_buffer, so every reconnect stranded the outgoing client's 64MB market-data and 16MB user-data rings. Neither is needed:WebSocketClient::subscribe()reopens a socket whose status is pastCONNECTED, which is whatCoinbaseLiveWSFeed::start()calls. -
The end-of-event marker sits on the update a batch dispatches last. Both enqueue loops walk their batch in reverse dispatch order — the element enqueued first takes the lowest
sequence_idandEventComparepops the highest first — soonLevel2Updatesflaggingstd::prev(rend())putF_END_EVENTon the element dispatched first. Every batch closed on its opening update, and a client batching until the marker acted on one update out of the whole batch.onLevel2Snapshotwas already correct: it applies its batch inline, with no queue to reverse it. -
onMarketTradessetsEvent::flags, which it never assigned.Event evt;default-initialises, sodispatchEvent's trade branch decidedis_last_in_batchfor a resting remainder from an indeterminate value. The field now also carries a default initialiser. -
A Coinbase l2 snapshot discards the events still queued for that symbol. A snapshot is a reset: it clears the book, and
poll()drains the callbacks that delivered it before runningprocessSequencedEvents, so anything left in the queue replayed onto the rebuilt book. A trade received before a reconnect would match against fresh liquidity, fabricate fills and remove quantity that was genuinely there —dispatchEvent's TRADE branch has no stale-timestamp guard, and the level branch's compares only against the rebuilt book. Everything queued when the snapshot arrives does predate it, since callbacks run on the exchange thread in arrival order. -
Exchange::reconcilePhantomQtyforwardsend_eventwhen a level's quantity increases, as its decrease branch already did. It reaches the book asChangeFlag::LastInBatch, whichSymbol::onPriceLevelUpdaterepublishes asF_END_EVENT, so an increase that was not its batch's final update closed the batch early and a client batching until the marker acted on half of one. -
A rejected
executeOrderends the match loop instead of silently re-matching. BothFifoMatchingEngine::matchoverloads discarded the return value, so a declined execution left the resting order at full quantity and the next pass matched it again — spinning until the aggressor's quantity ran out and publishing a fill every pass. The replay overload also gives back the quantity it had already deducted, so the caller does not rest a short remainder. -
OrderBook::executeOrderchecks the sequence before booking anything. It applied the fill to the simulator's ownOrderfirst and only then called the base, so a rejected execution still reported a fill to the client and, onceleaves_quantityreached zero, erased the order from the lookup maps while the L3 book kept it resting — phantom liquidity nothing could find again. The two mutations cannot simply be swapped: the base firesonOrderUpdate, which zeroesleaves_quantityon a full execution, andutils::executeOrderwould then subtract from zero.
Exchange::publishTradeSummarytakes aSymbol*rather than a symbol name, so it can stamp the security id and record the trade in the symbol's history.- Level reconciliation is shared:
Exchange::reconcilePhantomQtyreplaces the near-identical increase/decrease bodies inCoinbaseExchange::dispatchEventandHyperliquidExchange::applyPhantomLevelUpdate, and is where the double-reduction guard lives. Exchange::publishMDTradesandHyperliquidPublisher::publish_trade_updateare gone, along with bothtrade_update_buffer_members andCoinbaseExchange::trade_snapshots_. Their recent-trades role is served by the bounded per-symbolSymbol::trade_history_, fed by every published trade rather than only by relayed venue prints.MDUpdateType::TRADEis retained as a reserved enumerator so the values after it keep their numbering.Order'ssymbol,user_id,client_order_idandorder_idareutils::fixed_stringinstead ofstd::string, grouped into anOrderIdentitybase. Assignment,.empty(), comparison and formatting are unchanged at the call site. Two consequences: the order path no longer allocates for them — the 36-characterorder_idalways exceeded the small-string buffer — and a response header is filled by onesetOrderIdentity()memcpy of the whole block rather than four copies out of four unrelated heap blocks.messages.hppstatic_asserts the layout againstOrderResponse, so resizing a field on either side fails the build.Symbol::symbol_andOrderBook::symbol_aresymbol_name_t, declared inmarket_data.hppasutils::fixed_string<sizeof(MarketDataUpdate::symbol)>— derived from the wire field rather than spelled out, so widening that field widens the storage with it instead of silently reintroducing the over-read at every publish site.OrderBook::symbolName()returns it, andExchange::publishLevelUpdate/publishMDOrderUpdatetake it in place of a bareconst char*, so the width guarantee survives the call.OrderBook'sorders_by_client_order_id_andorders_by_order_id_are keyed byutils::fixed_string<37>rather thanstd::string, removing a second heap allocation per resting order; the transparentStringViewHash/StringViewEqfunctors takestd::string_view, so lookups bystring_viewstill find afixed_stringkey unchanged. Both functors moved from private members ofOrderBooktoutils, sinceclient_ids_now needs the same transparent lookup;OrderBookkeeps its old names as aliases.- The Coinbase publisher serialises each market-data message once instead of once per subscriber.
Every subscriber gets its own
sequence_num, so the payload differed by a single integer — butdump()sat inside the fan-out loop, walking the whole document and building a fresh string per socket.md_publisher::SequencedMessagedumps once and splices the number in withstd::to_charsinto a reused buffer: ~47x faster on a ten-levell2_dataupdate with 50 subscribers. Applies to level updates, book snapshots, subscription snapshots and trade prints; the remainingdump()calls are genuinely per-socket content. Hyperliquid already sent one shared payload and is unchanged. OrderBook::allocateOrderno longer constructs aboost::uuids::random_generatorper call, which seeded a Mersenne twister from the OS entropy source for every order. It is astatic thread_localnow, and the UUID is rendered withto_charsstraight into the order's own buffer instead of through aboost::lexical_cast<std::string>temporary.Exchange::clientIdForlooks the user up bystd::string_viewand materialises a key only when the user is new. It ran once per new order and built astd::stringevery time purely to probe the map — an allocation for anyuser_idpast the small-string buffer, on a path whose whole point is not to allocate.client_ids_is keyed byutils::fixed_string<sizeof(AddOrderMessage::user_id)>so the stored key is inline too.canFillCompletelyno longer resolves each resting order to its fullOrderwhen self-match prevention is off — that lookup exists only to compare client ids, and it was a hash lookup per order on every fill-or-kill pre-check. Its redundantavailable_qtyaccumulator and unreachable post-loop check are gone; the early return already covers every path that finds enough liquidity.Symbol's market-data caches reserve 256 levels and 1024 orders, up from 64 and 256, andTradeSummaryInforeserves four legs inTrades— a summary covers one price level, so even the common two-leg fill reallocated twice growing 1 → 2. Every call site only everclear()s these, so the reserved capacity is what they keep for the process's lifetime.WebsocketMarketDataPublisher::publish_processingdrains up to 1000 updates per pass instead of 100, so a market-data flood costs oneloop_->deferper 1000 updates rather than per 100. The reschedule stays unconditional and is now commented to say why: that defer is the only thing that runs the function again, sincemd_queue_is lock-free with no wakeup and the exchange thread never touches the loop, so skipping it on an empty queue would stop the publisher permanently the first time it caught up.- Venue code is separated from the core and each venue builds optionally. Everything specific to a
venue — its
Exchangesubclass, market-data feed, order gateways, publisher and wire encoders — moved out of the five component directories into a singlesrc/venues/<venue>/directory built as one target.SLICK_SIM_ENABLE_COINBASEandSLICK_SIM_ENABLE_HYPERLIQUID(both defaultON) gate that target, its unit tests and the venue's vendor SDK, so a Hyperliquid-only build neither fetches nor links the Coinbase SDK. No core target contains venue code or links a venue SDK. Adding a venue is a new directory plus one line inSLICK_SIM_VENUE_LIST; seedocs/extending.md. - A venue resolves its own dependencies in its own
CMakeLists.txt— its vendor SDK and its networking stack, the latter declared in theDEPENDSof itsslick_sim_add_venue()call. The top-level file names no venue library at all, andSLICK_SIM_ENABLE_<VENUE>is tested in exactly one place:src/venues/CMakeLists.txt, which adds the directory. Those lookups passGLOBAL, without which the imported targets would be invisible toslick-simandslick_sim_testsin their sibling scopes.slick::netbelongs to Coinbase and Hyperliquid, whose gateways speak HTTP, instead of being linked on "any venue is enabled" — which would have forced it onto a CME or ICE adapter that speaks a binary session protocol overslick::socketand never calls it. It previously arrived only as a transitive requirement of the venue SDKs, so a build with both venues off failed to link.main.cpp's slick-net log bridge is gated onSLICK_SIM_HAS_SLICK_NET, derived from the venues'DEPENDSsomain.cppstill names no venue, in place ofSLICK_SIM_HAS_ANY_VENUE.jwt-cppmoved to Coinbase with the rest: only its two gateways sign with it. - uWebSockets left the core too.
rest_ws_order_gateway.cppandws_md_publisher.cppare the project's only uWebSockets translation units, and only a venue serving HTTP/WebSocket derives fromRestWsOrderGateway/WebsocketMarketDataPublisher, so they compile intoslick_sim_rest_ws— a target created on demand byslick_sim_require_rest_ws()from the venue that asks for it.market_data_publisherbecomes an INTERFACE target (that source was its only one), andorder_gatewaycarries just the TCP/FIX/SBE subsystem and its three parsers, becoming INTERFACE when the newSLICK_SIM_ENABLE_TCP_GATEWAY(defaultON) is off — which also stops it linkingslick::socket, now resolved behind that option rather than for every build, and drops QuickFIX from the required dependencies entirely. A core-only build needs none of uWebSockets, jwt-cpp, QuickFIX, slick-socket or slick-net: nothing in the core links a wire protocol. - Venues resolve through a new
ExchangeRegistryinstead of a hard-codedif/elseinmain.cpp, which now names no venue. Each adapter self-registers viaSLICK_SIM_REGISTER_VENUE, and config keys are matched case-insensitively. An enabledexchangeskey with no matching adapter is now a fatal startup error naming the adapters the binary carries, instead of silently constructing an inertExchange;"enabled": falseis checked before the lookup, so a disabled block still needs no adapter. Exchangesubclasses overridepoll()rather thanstart(). The spin loop moved into the baseExchange::start(), which callspoll(); processRequest();each tick;poll()defaults to a no-op. Both adapters' near-duplicatestart()overrides were deleted, and themd_feeds_vector each declared separately is now a single base member.- The
Venueenum,to_stringandto_venueare generated from oneSLICK_SIM_VENUE_LISTX-macro. Enumerator values are unchanged.to_venueis genuinely case-insensitive now — it previously matched only the exact all-upper or all-lower spelling, so"Coinbase"resolved toUNKNOWN_VENUEdespite the docs promising otherwise.
src/market_data_publisher/market_data_publisher.cpp— commented out of the build, with a ctor signature that no longer matched its header and a staleswitch(venue)factory the registry replaces.src/exchange/exch_cme.hppandsrc/md_feed/alpaca_historical_data_feed.hpp— empty placeholders.
-
The Ubuntu Release CI job no longer fails intermittently with
SIGILL. The vcpkg cache is now keyed on the runner's CPU feature set, not just the OS. The SlickQuant dependency libraries (slick-net,hyperliquid-cpp, …) build their Release config with-march=native, so vcpkg produced binaries tuned to whichever runner compiled them; restoring that cache onto a runner with a narrower instruction set made the tests die on an AVX-512 opcode (vmovdqu8) insideslick::net::Websocket's constructor, reached fromHyperliquidRestOrderGateway. That is why a re-run sometimes passed — it depended on the CPU the cache came from. -
Release builds on GCC/Clang no longer pass
-march=native. It is now behind a newENABLE_NATIVE_ARCHoption, off by default. Baking the build machine's instruction set into the binary broke two things: the Ubuntu Release CI job started failing withSIGILLin every test that exercises the exchange and order-book paths, and — more seriously —ci.ymluploads those Release binaries forrelease.ymlto attach to GitHub Releases, so published builds would fault on any user CPU older than the runner that produced them. The flag is still available for local and self-hosted production builds via-DENABLE_NATIVE_ARCH=ON. -
Exchange::start()no longer dereferences a nullmd_publisher_. OnlyCoinbaseExchangeandHyperliquidExchangeconstruct a publisher, so any other venue key — which falls through to the genericExchange— segfaulted the process on startup. Becausenlohmann::jsoniterates object keys in sorted order, thecmeblock in the committed sample config crashedslick-simbefore either working venue was even constructed. -
enabledis now honoured for every venue. It is read once in theExchangebase constructor and checked by all threestart()implementations, which return early with a warning when it is false. Previously the check lived only in thecoinbaseandhyperliquidbranches ofmain.cpp, so"enabled": falseon any other venue was silently ignored.
- Sample config (
config/slick_sim.json):- The
cmeblock is now"enabled": false, uses anmbo_live_feedplaceholder feed instead of a copied Hyperliquid one, and no longer collides with Hyperliquid's ports — its order gateway moves to 4003 and its market data publisher to 5002 (it previously shared Hyperliquid's 5001). - Hyperliquid's order gateway port moves from 4003 to 4002.
- Dropped
coinsfrom the Hyperliquid feed entry.
- The
- Documentation site published to GitHub Pages at
https://slickquant.github.io/slick-sim/ — MkDocs Material for the narrative
documentation at the root, Doxygen for the C++ API reference at
/api. - Eleven documentation pages under
docs/, in two tracks:- Internals — architecture and threading model, order lifecycle, matching engine, order book, market data, and a guide to adding an exchange.
- Integration — full configuration reference, plus Coinbase and Hyperliquid client guides covering the REST routes, WebSocket channels and message shapes actually implemented.
- A Known gaps page recording unimplemented and half-wired code paths.
documentation.ymlworkflow: builds both halves of the site, deploys togh-pageson pushes tomain, uploads a preview artifact for pull requests, and lints Markdown and checks its links. It runs Doxygen directly rather than configuring CMake, so it does not need the vcpkg dependency chain.Doxyfile.inand a CMakedocstarget for generating the API reference locally (cmake --build build --target docs). The target is skipped when Doxygen is not installed..markdownlint.jsonand.github/markdown-link-check-config.json.
- Initial exchange simulator: Coinbase and Hyperliquid adapters with live market data mirroring, FIFO matching engine, and venue-native order entry/market data gateways