Adding an exchange¶
This is a checklist for implementing a new venue adapter, derived from the two that work. Read Architecture first — the threading model determines most of the design.
Concretely you need five pieces: a Venue enum entry, an Exchange subclass, an MDFeed, an
OrderGateway, and a MarketDataPublisher. Everything venue-specific lives in one directory,
src/venues/<name>/, built as one optional target — so beyond the enum line, the only wiring is that
directory's own CMakeLists.txt.
Nothing in the core, and no existing venue, has to be edited to add a new one.
1. Add the Venue enumerator¶
src/common/types.hpp
generates the enum, to_string and to_venue from one X-macro list, so a venue is a single line:
#define SLICK_SIM_VENUE_LIST(X) \
X(CME, "CME") \
X(ICE, "ICE") \
X(COINBASE, "COINBASE") \
X(HYPERLIQUID, "HYPERLIQUID") \
X(STOCK, "STOCK") \
X(KRAKEN, "KRAKEN") // ← append here
to_venue matches case-insensitively, so kraken, KRAKEN and Kraken all resolve.
Append only
Venue is written into every MarketDataUpdate frame and into the shared-memory md queue.
Inserting in the middle renumbers every venue after the insertion point, which silently
reinterprets any queue or capture file another process is reading.
This list is deliberately not conditional on the build options. A Venue is an identity on the
wire, not a claim that an adapter was compiled in: to_venue("kraken") still resolves in a build with
the Kraken adapter switched off, which is what lets a capture recorded elsewhere be read here.
2. Subclass Exchange¶
class KrakenExchange : public Exchange, public md_feed::KrakenFeedCallbacks {
public:
explicit KrakenExchange(const nlohmann::json& config);
protected:
void poll() override; // the per-tick pump
void handleMdSubscription(const Request& request) override;
void handleMdUnsubscription(const Request& request) override;
private:
Symbol* addSymbol(std::string_view symbol);
};
Constructor¶
Call Exchange(Venue::KRAKEN, config) first — the base constructor builds the three queues and throws
if order_gateway or md_publisher is missing. Then construct your publisher, gateways and feeds:
KrakenExchange::KrakenExchange(const nlohmann::json& config)
: Exchange(Venue::KRAKEN, config)
{
md_publisher_ = std::make_unique<KrakenPublisher>(config["md_publisher"], request_queue_, md_queue_);
order_gateways_.emplace_back(
std::make_unique<KrakenRestOrderGateway>(config["order_gateway"], request_queue_, response_queue_));
for (const auto& feed_cfg : config.value("md_feeds", nlohmann::json::array())) {
if (feed_cfg.value("type", "") == "kraken_live_ws") { /* … */ }
}
}
poll() — the per-tick pump¶
Do not override start(). The base class owns the thread and the loop; you supply only what runs
each iteration:
Exchange::start() starts the gateways, the publisher and every feed in md_feeds_, then spins
poll(); processRequest(); until stop(). poll() defaults to doing nothing, which is exactly right
for a venue with no live feed.
You do not need to override stop() either. The base stops the feeds, gateways and publisher and
joins the exchange thread — in that order, joining the thread before touching md_feeds_, because
poll() and handleMdUnsubscription both run on it. It is also idempotent: main() stops each
exchange and ~Exchange() stops it again, so the second pass returns immediately rather than
unsubscribing a feed twice.
Symbol creation¶
Symbol* KrakenExchange::addSymbol(std::string_view symbol) {
auto* sym = SymbolManager::instance().createSymbol(symbol, Venue::KRAKEN);
if (!matching_engines_[engine::MatchingEngine::Type::FIFO]) {
matching_engines_[engine::MatchingEngine::Type::FIFO] =
std::make_unique<engine::FifoMatchingEngine>(response_queue_);
}
sym->matching_engine_ = matching_engines_[engine::MatchingEngine::Type::FIFO].get();
sym->createOrderBook<OrderBookType::L2>();
return sym;
}
Use L2, not L3 — the L3 specialisation's populate* methods are stubs.
The engine is created lazily and shared by every symbol on the venue.
Remember SymbolManager is global and keyed by symbol name across all venues. If your venue's symbols
could collide with an existing venue's, namespace them.
Subscription handling¶
handleMdSubscription runs on the exchange thread. The pattern both adapters follow:
- Look up the symbol;
addSymbolit if new. - Increment
num_subscriptions_. - If using a live feed and the symbol is new, attach it to a feed and insert it into
pending_md_subscription_[channel]— the snapshot will come from upstream. - If the symbol already has a book and is not pending, call
populateL2SubscriptionResponse(md_queue_, channel)immediately.
Track pending state per channel, not per symbol: a client can subscribe to a second channel for a symbol that already has an active subscription on the first.
Also implement handleMdUnsubscription — the request loop dispatches MD_UNSUBSCRIPTION to it, so
an adapter that omits it leaks upstream subscriptions.
Feed callbacks¶
Configure the venue SDK for user-thread dispatch, and pump it from the exchange loop. Both existing adapters do this, and it is what keeps the whole market-data path single-threaded and lock-free:
| Venue | Mechanism | Pumped by |
|---|---|---|
| Coinbase | Implement coinbase::UserThreadWebsocketCallbacks; the WS client fills a slick::stream_buffer_multiplexer |
processData() in the exchange loop |
| Hyperliquid | Construct hyperliquid::Info with user_thread_dispatch = true |
getInfo()->dispatch(100) in drainEventQueue() |
With that in place the SDK's network thread only buffers bytes, and your callbacks fire on the exchange thread — so they may touch the book directly. If your SDK offers no such mode and insists on calling back from its own thread, then you must enqueue and drain, because the book has no locking whatsoever.
The existing adapters still queue events, but for sequencing, not thread safety:
void KrakenExchange::onBookUpdate(const nlohmann::json& msg) {
event_queue_.push_back({FeedEvent::Type::BOOK, msg}); // deferred, not cross-thread
}
Hyperliquid uses a plain std::vector<FeedEvent> drained immediately after the dispatch() call
that filled it, which keeps book mutation out of the SDK's callback re-entrancy. Coinbase uses a
per-symbol std::priority_queue to re-order by exchange timestamp, because its channels can deliver
out of order — see Market data. Pick based on
what your venue actually needs; a venue that delivers in order needs neither.
Applying book updates¶
For a new price level, match against the opposite side before resting the phantom order, so a quote that crosses a resting simulator order fills it:
qty_t qty = level_qty;
auto order_id = utils::nextOrderId();
symbol->matching_engine_->match(side, order_id, price, qty, *symbol->order_book_, event_time_ns, seq);
if (qty > 0) {
symbol->order_book_->addOrder(order_id, to_book_side(side), price, qty, event_time_ns, seq, true);
}
For an existing level, adjust phantom quantity only, using getMDLevelQty() as the baseline and
skipping any order that Symbol::findOrder() resolves. HyperliquidExchange::applyPhantomLevelUpdate
is the cleanest reference implementation.
For a full snapshot, prefer clearMDOrders() over clear() — it preserves resting simulator
orders.
3. Implement the feed (optional)¶
class KrakenLiveWSFeed : public md_feed::MDFeed {
public:
void start() override; // subscribe upstream
void stop() override; // unsubscribe
};
If the SDK supports user-thread dispatch, use it (as HyperliquidLiveWSFeed does with
user_thread_dispatch = true) and pump it from the exchange loop — it removes a whole class of race.
A feed is genuinely optional: an adapter with none still works
self-contained, matching clients
against each other. Gate the feed-specific paths on a use_live_feed_-style flag the way both
existing adapters do, so the same adapter serves both modes.
A historical replay feed implements this same interface — start() begins replaying recorded
data and stop() halts it — and drives the book through exactly the same phantom-order calls
described below. Nothing downstream needs to know the data is not live. None exists yet; see
Known gaps.
4. Implement the order gateway¶
For an HTTP/WebSocket venue, subclass
RestWsOrderGateway,
which handles the uWS thread, listen socket, and clean shutdown. You implement two methods:
std::string_view type() const noexcept override { return "REST"; }
void setup_routes(uWS::App& app) override;
Inside a route handler, the request side is:
auto index = request_queue_.reserve();
auto* request = request_queue_[index];
request->time_stamp = utils::get_current_time_ns();
utils::copy_wire_field(request->symbol, sym);
request->msg_type = MessageType::NEW_ORDER_SINGLE;
// … fill request->add_order …
request_queue_.publish(index);
Always fill the fixed-size char arrays with
utils::copy_wire_field.
Request is recycled memory and the reader parses these fields as C strings, so a hand-written copy
has three things to get right and the obvious spellings each miss one:
memcpy(dst, s.c_str(), sizeof(dst))reads past the end of any name shorter than the field, and drops whatever followed it in memory into a field the reader treats as a C string.memset+memcpy(dst, s.c_str(), std::min(sizeof(dst), s.size()))is bounded but clamps one byte too late: a value that exactly fills the field overwrites the terminator thememsetleft.- Neither clears the tail on a shorter value written over a longer one in a recycled slot.
copy_wire_field truncates to N-1, zero-fills the remainder, and returns true when the value did
not fit, for callers that want to warn.
For the response side you have two options:
- Synchronous (Coinbase REST, Hyperliquid REST): capture
response_queue_.initial_reading_index()before publishing, then pollread()until a response matches. Correlate onuser_id+client_order_idfor new orders, or onrequest_timefor modify/cancel where the reject helpers do not populate the identifiers correctly. Note this blocks the uWS loop thread. - Asynchronous (Coinbase WS): schedule a recurring drain on the loop with
loop_->defer, look up the client socket byuser_id, and push the report.
5. Implement the market-data publisher¶
Subclass
WebsocketMarketDataPublisher
and implement three methods:
void setup_routes(uWS::App& app) override; // ws<PerSocketData>("/*", {...})
void check_heartbeats() override; // called on a timer
void publish_market_data_update(MarketDataUpdate* update) override; // switch on update->type
Handle at least SUB_RESPONSE (first snapshot after subscribe) and BOOK_SNAPSHOT (routine updates).
Handle TRADE_SUMMARY too if your venue has a trades channel: it carries one executed trade, and it
is the only source of the public tape. Do not expect a raw relay of the venue's own prints — those
are replayed through the matching engine and reach you as fills.
On a client subscribe message, write an MD_SUBSCRIPTION request:
auto index = request_queue_.reserve();
auto* request = request_queue_[index];
utils::copy_wire_field(request->symbol, coin);
request->msg_type = MessageType::MD_SUBSCRIPTION;
request->md_subscription.channel = channel_index;
request->md_subscription.client = (void*)ws;
request_queue_.publish(index);
Track subscriptions in both directions — symbol → set of sockets (for fan-out) and per-socket pending/active sets (to know when to send the subscription confirmation).
6. Register the venue¶
One small translation unit in your venue directory, src/venues/kraken/kraken_venue.cpp:
#include "kraken_exchange.hpp"
#include <exchange/exchange_registry.hpp>
#include <nlohmann/json.hpp>
using namespace slick::sim;
using namespace slick::sim::exch;
SLICK_SIM_REGISTER_VENUE("kraken", Venue::KRAKEN, KrakenExchange);
The string is the config key under exchanges, matched case-insensitively. main.cpp names no venue
and needs no edit; an enabled key with no registered adapter is a fatal startup error that lists what
the binary does carry.
7. Wire up the build¶
Create src/venues/kraken/CMakeLists.txt:
slick_sim_add_venue(kraken
SOURCES
kraken_exchange.cpp
kraken_live_ws_feed.cpp
kraken_rest_order_gateway.cpp
kraken_publisher.cpp
kraken_venue.cpp
DEPENDS
kraken-sdk # whatever your venue SDK's imported target is called
slick::net # your networking stack — see below
slick_sim_rest_ws # the uWebSockets bases, if you serve HTTP/WebSocket
)
DEPENDS is where the adapter names everything it links that the core does not, including its
networking stack. Coinbase and Hyperliquid speak HTTP/WebSocket and name slick::net; an adapter for
a binary session protocol — CME iLink, ICE — names slick::socket and must not name slick-net. No core
target links either, and no venue inherits another's, so an adapter gets exactly what it declares.
Resolve your dependencies in that same file, above the slick_sim_add_venue() call — not in the
top-level CMakeLists.txt. The directory is added only when your option is on, so nothing here re-tests
it:
find_package(kraken-sdk CONFIG QUIET GLOBAL) # or FetchContent
if (NOT kraken-sdk_FOUND)
FetchContent_Declare(kraken-sdk GIT_REPOSITORY ... GIT_TAG ...)
FetchContent_MakeAvailable(kraken-sdk)
endif()
# Your networking stack. A no-op if the SDK above already brought it in as a
# build-tree target, which find_package cannot see.
slick_sim_find_venue_dependency(slick-net slick::net)
# Only if your gateway derives from RestWsOrderGateway or your publisher from
# WebsocketMarketDataPublisher: creates the shared target that compiles them, and
# resolves uWebSockets. Nothing in the core does either, so a venue that serves a
# binary session protocol never pulls uWebSockets into the build.
slick_sim_require_rest_ws()
find_package in a venue directory needs GLOBAL
An imported target is visible only in the directory that created it and below, while slick-sim
(src/) and slick_sim_tests (tests/) link your object library from sibling scopes and have to
resolve every name in its interface. Without GLOBAL, generate fails with "links to target
kraken-sdk but the target was not found". slick_sim_find_venue_dependency() passes it for you;
a find_package you write yourself must pass it too. Targets created by FetchContent are real,
not imported, so they need nothing.
Two lines then remain outside your directory — the option, in the top-level CMakeLists.txt, and the
subdirectory, in src/venues/CMakeLists.txt:
That is the whole build change. slick_sim_add_venue links your target against exchange (which
carries every core header transitively), defines SLICK_SIM_HAS_KRAKEN for consumers, and adds the
venue to the list that slick_sim_link_venues() feeds to both slick-sim and slick_sim_tests.
Because DEPENDS is linked PUBLIC, your SDK and your networking stack reach the executable and the
test binary without either of them naming a venue library.
If your stack is slick::net, main.cpp's bridge from its log output into slick-logger switches on by
itself: slick_sim_link_venues() defines SLICK_SIM_HAS_SLICK_NET when any enabled venue's DEPENDS
names it. A different stack with its own log handler gets its own flag the same way — one if in
slick_sim_link_venues() keyed on the library, never on "a venue is enabled".
Venue targets must be OBJECT libraries
slick_sim_add_venue declares one, deliberately. SLICK_SIM_REGISTER_VENUE registers from a
namespace-scope initialiser that nothing in the core ever references by name — out of a static
library the linker discards the whole object as unreferenced, and the venue vanishes from the
registry with no build error at all. ExchangeRegistryTest.EnabledVenuesAreRegistered exists to
catch exactly that regression.
8. Tests¶
Put the suites in tests/unit/venues/kraken/ and add a gated block to tests/CMakeLists.txt — keyed
on the target, so the suite disappears with the venue:
if(TARGET slick_sim_venue_kraken)
list(APPEND SLICK_SIM_TEST_SOURCES
unit/venues/kraken/test_kraken_exchange.cpp
)
endif()
Mirror the existing suites:
| Reference | What to copy |
|---|---|
tests/unit/venues/coinbase/test_coinbase_exchange.cpp |
Adapter-level: subscription handling, snapshot application, phantom-level deltas |
tests/unit/matching_engine/test_feed_liquidity_matching.cpp |
Feed liquidity crossing a resting simulator order (venue-agnostic) |
tests/unit/venues/hyperliquid/test_hyperliquid_info_proxy.cpp |
Pure request/response translation, tested without a network |
tests/unit/test_helpers.hpp has the shared fixtures — from a venue directory that is
#include "../../test_helpers.hpp". The project convention (from CLAUDE.md) is that every bug fix
gets a regression test.
9. Check it builds without the others¶
The point of the split is that venues are independent. Verify that:
cmake -S . -B build-kraken -DSLICK_SIM_ENABLE_COINBASE=OFF -DSLICK_SIM_ENABLE_HYPERLIQUID=OFF
cmake --build build-kraken
ctest --test-dir build-kraken
Configuring with every venue off must still build and link the core — that is the configuration that catches a core target having quietly picked up a dependency through a venue SDK.
Checklist¶
- [ ] One line appended to
SLICK_SIM_VENUE_LISTintypes.hpp - [ ] Everything venue-specific under
src/venues/<name>/— nothing in the core directories - [ ]
Exchangesubclass overridingpoll(), notstart() - [ ]
addSymbol()creating anL2book and attaching the shared FIFO engine - [ ]
handleMdSubscription/handleMdUnsubscriptionwith per-channel pending tracking - [ ] Feed callbacks that enqueue only — no book mutation off the exchange thread
- [ ]
MDFeedimplementation (optional — omit for a self-contained venue) - [ ]
OrderGateway(RestWsOrderGatewaysubclass) withsetup_routes - [ ]
MarketDataPublisher(WebsocketMarketDataPublishersubclass) handlingSUB_RESPONSE,BOOK_SNAPSHOT,TRADE_SUMMARY - [ ]
SLICK_SIM_REGISTER_VENUEin a<name>_venue.cpp - [ ]
slick_sim_add_venue()CMakeLists naming your networking stack inDEPENDS, your SDK lookup in the same file (withGLOBAL), and theSLICK_SIM_ENABLE_<VENUE>option - [ ] Config block documented in Configuration
- [ ] Unit tests under
tests/unit/venues/<name>/, gated onif(TARGET slick_sim_venue_<name>) - [ ] Builds and tests pass with every other venue disabled