Architecture¶
Operating modes¶
The market-data feed is optional, and what you configure under md_feeds decides what kind of
simulator you get. The rest of the system — gateways, order book, matching engine, publisher — is
identical in every case; only the source of the resting liquidity changes.
| Mode | md_feeds |
Liquidity in the book | Status |
|---|---|---|---|
| Shadowing | A live feed (coinbase_live_ws / hyperliquid_live_ws) |
Real venue quotes, mirrored continuously | Implemented |
| Self-contained | Omitted, or no recognised type |
Only the orders clients send | Implemented |
| Historical replay | A historical feed | Recorded market data, replayed | Not implemented |
Shadowing — matching against the live market¶
The mode slick-sim is built for. It subscribes to the real venue's live public market data and
reconstructs that venue's book locally, order by order. Your orders are inserted into that same book
and matched against real, currently-quoted liquidity by a local FIFO engine.
Prices and trades are therefore real; fills are simulated. Nothing is ever sent upstream except
market-data subscriptions and — for Hyperliquid — read-only /info metadata queries.
Self-contained — a conventional exchange simulator¶
With no feed configured, the same machinery behaves like a conventional exchange simulator: books start empty and the only liquidity is what clients themselves submit. Orders rest, match against each other under the same FIFO price/time priority, and produce the same execution reports and venue-native market data. Useful for deterministic tests, for exercising a client's order-entry path without depending on the internet, and for multi-client scenarios where you want full control over both sides of every trade.
Both adapters gate this on an internal use_live_feed_ flag, set only when md_feeds contains an
entry whose type the adapter recognises. Omit md_feeds entirely, or give it an unrecognised
type, and the flag stays false.
You still need a market-data subscription to trade a symbol
Symbol objects are created lazily by handleMdSubscription, and that is the only path that
creates them. Even in self-contained mode a client must subscribe to the symbol's market data
before sending orders for it, or they are rejected with UNKNOWN_CONTRACT. The subscription
simply returns an empty book instead of a live snapshot.
Historical replay — planned¶
A historical feed would implement the same MDFeed interface and drive the book from recorded data
rather than a live socket, so clients' orders match against a past market at whatever rate the replay
runs. Everything downstream is unchanged: the same phantom-order model, the same matching engine, the
same venue-native output.
Nothing implements this yet — alpaca_historical_data_feed.hpp is a zero-byte placeholder and
md_feed's CMakeLists.txt compiles only the Hyperliquid live feed. See
Known gaps and, for what a feed has to
provide, Adding an exchange.
Phantom orders and simulator orders¶
Whenever a feed is driving the book — live today, historical later — the book holds two kinds of liquidity:
- Phantom orders — synthesised from the market-data feed to represent the market's resting liquidity. They are anonymous: the simulator knows their price, quantity and queue position, but nothing else.
- Simulator orders — the orders your client actually sent. They rest in the same book, at the same price levels, competing for the same queue positions.
The book cannot distinguish them structurally; it distinguishes them by bookkeeping. This split is the single most important idea in the codebase and is covered in detail in Order book.
In self-contained mode the book simply contains no phantom orders. Nothing else changes — the same
code paths run, feed_md_level_quantity_ stays empty, and every fill comes from one client order
crossing another.
The per-exchange pipeline¶
Every enabled venue gets its own complete, independent instance of this pipeline. Two venues share
nothing except the SymbolManager singleton and the process-global order-id and priority counters.
The dashed portion is the market-data source, which is optional — without it the pipeline runs unchanged and the book holds only client orders.
flowchart TB
subgraph upstream["Market data source (optional)"]
RE["Coinbase / Hyperliquid<br/>public WebSocket feed"]
end
subgraph sim["slick-sim (one instance per venue)"]
FEED["md_feed<br/>CoinbaseLiveWSFeed / HyperliquidLiveWSFeed"]
EX["Exchange<br/>CoinbaseExchange / HyperliquidExchange"]
SYM["Symbol<br/>one per instrument"]
BOOK["OrderBook (L3)<br/>simulator orders<br/>+ phantom orders if a feed is configured"]
ME["FifoMatchingEngine"]
OG["order_gateway<br/>REST / WebSocket"]
MDP["market_data_publisher<br/>WebSocket"]
end
subgraph client["Trading client"]
CO["order entry"]
CM["market data"]
end
RE -.->|"live book + trades"| FEED
FEED -.->|"callbacks on WS thread"| EX
EX --> SYM
SYM --> BOOK
SYM --> ME
ME --> BOOK
CO -->|"orders (REST/WS)"| OG
OG -->|"request_queue"| EX
EX -->|"response_queue"| OG
OG -->|"acks / fills / cancels"| CO
CM -->|"subscribe (WS)"| MDP
MDP -->|"request_queue"| EX
EX -->|"md_queue"| MDP
MDP -->|"venue-native WS"| CM
The two client-facing paths are fully independent, and each is bidirectional:
- Order entry —
order_gatewayaccepts orders from the client and returns acks, fills, cancels and rejects to that same client, over the same connection. - Market data —
market_data_publisheracceptssubscribe/unsubscribemessages from the client and streams book and trade updates back.
A client's subscription request never touches order_gateway. The publisher receives it on its
own WebSocket and writes an MD_SUBSCRIPTION Request onto request_queue_ itself — so the
publisher is a producer for that queue exactly as the gateways are, and Exchange::processRequest
sees order requests and subscription requests interleaved on one queue.
Library targets¶
The build produces one executable, slick-sim, from
src/sim/main.cpp,
linked against a handful of static libraries.
| Target | Directory | Responsibility |
|---|---|---|
exchange |
src/exchange/ |
Per-venue orchestration (Exchange, CoinbaseExchange, HyperliquidExchange) and Symbol, which binds one instrument's book to a matching engine |
matching_engine |
src/matching_engine/ |
MatchingEngine interface, FifoMatchingEngine, and the four order-lifecycle publish helpers |
order_gateway |
src/order_gateway/ |
Venue-native REST/WebSocket order entry, plus the unused generic TCP/FIX/SBE gateway |
market_data_publisher |
src/market_data_publisher/ |
Re-encodes internal market data into each venue's own WebSocket format |
md_feed |
src/md_feed/ |
Live market-data ingestion from the real venue |
| (header-only) | src/common/, src/order_book/, src/utils/ |
Shared types, the order book, and small helpers — no compiled target, included directly |
Threading model¶
This is the part most likely to trip you up, because it is not what the class structure suggests.
Threads per exchange¶
One exchange thread. Created in the concrete adapter's start(), it runs a tight spin loop that
does all book mutation and all order processing:
// CoinbaseExchange::start()
thread_ = std::thread([this]() {
while (run_.load(std::memory_order_relaxed)) {
if (md_feed_ || !md_feeds_.empty()) {
processData(); // drain the WS stream multiplexer
}
processSequencedEvents(); // release time-ordered book events
processRequest(); // one order request per iteration
}
});
HyperliquidExchange is the same shape with drainEventQueue() in place of the first two calls.
Note there is no sleep and no condition variable — the thread spins at 100% of a core by design.
The base class does not loop
Exchange::start() calls processRequest() exactly once, with no loop. Only the two concrete
adapters override it correctly, so a config key other than coinbase or hyperliquid yields an
exchange that binds no ports and processes at most one request. See
Known gaps.
One uWebSockets event-loop thread per gateway and per publisher. RestWsOrderGateway::start()
and WebsocketMarketDataPublisher::start() each spawn a thread that builds a uWS::App, installs
routes, and calls .run(). For Coinbase that is three such threads (REST gateway, WS gateway, MD
publisher); for Hyperliquid, two.
Venue-SDK WebSocket threads. The Coinbase and Hyperliquid client libraries run their own network threads and invoke callbacks from them.
Which thread runs what¶
| Code | Thread | Notes |
|---|---|---|
Exchange::processRequest, handleNewOrderRequest, Symbol::addOrder, FifoMatchingEngine::match, all publish* |
Exchange thread | The only thread that mutates an OrderBook |
CoinbaseExchange::onLevel2Updates, onMarketTrades |
Coinbase WS thread | Push Events into the per-symbol priority queue; no book mutation |
CoinbaseExchange::onLevel2Snapshot |
Coinbase WS thread | Does mutate the book directly, unlike the incremental path |
HyperliquidExchange::onL2BookUpdate, onTradesUpdate |
Hyperliquid WS thread | Append to event_queue_ only |
HyperliquidExchange::processL2Snapshot, processL2Diff |
Exchange thread | Drained from event_queue_ by drainEventQueue() |
| REST route handlers, WS message handlers | That gateway's uWS loop thread | Write to request_queue, poll response_queue |
CoinbasePublisher::publish_*, HyperliquidPublisher::publish_* |
Publisher's uWS loop thread | Read md_queue, write to client sockets |
CoinbasePublisher::handle_message, HyperliquidPublisher::handle_message |
Publisher's uWS loop thread | Client subscribe/unsubscribe — writes MD_SUBSCRIPTION to request_queue |
The gateway threads deserve a closer look. POST /api/v3/brokerage/orders publishes a request and
then blocks its uWS loop thread, polling response_queue every 100 µs for up to 5 seconds waiting
for the matching engine's reply. That is a synchronous request/response bridge across a lock-free
queue, and while it is waiting, that gateway's event loop serves no other client. Cancel and edit use
the same pattern with a 200 ms timeout.
Queue topology¶
Three slick::queue instances per exchange, constructed in the
Exchange constructor:
request_queue_ (config.value("request_queue_size", 1048576), …)
response_queue_(config.value("response_queue_size", 1048576), …)
md_queue_ (config.value("md_queue_size", 16777216), …)
| Queue | Element | Producers | Consumer |
|---|---|---|---|
request_queue_ |
Request |
Every order gateway, and the market-data publisher (for subscriptions) | Exchange thread |
response_queue_ |
OrderResponse |
Exchange thread (via matching-engine publish helpers and the reject/pending senders) | Every order gateway |
md_queue_ |
uint8_t (variable-length frames) |
Exchange thread | Market-data publisher |
md_queue_ is a byte queue rather than a typed one because market-data messages are
variable-length. Producers call reserve(size) to get a slot index, cast the returned pointer to
MarketDataUpdate*, fill the trailing flexible-array payload, then publish(index, size). See
Market data.
Consumers hold their own cursor and call read(cursor); each consumer advances independently, so a
response queue with several gateways attached delivers every response to every gateway, and each one
filters by user_id.
Shared-memory queues¶
Each queue also accepts an optional shared-memory name:
"request_queue_shm_name": "slick_sim_coinbase_req",
"response_queue_shm_name": "slick_sim_coinbase_rsp",
"md_queue_shm_name": "slick_sim_coinbase_md"
When present, slick::queue backs the ring with a named shared-memory segment instead of process
memory, which would let an external process attach to the same queue. These keys are read by the
Exchange constructor but appear nowhere in the sample config and are not otherwise exercised.
Symbol management¶
SymbolManager
is a process-wide singleton, not per-exchange:
exch::Symbol* getSymbol(std::string_view sym) const;
exch::Symbol* createSymbol(std::string_view sym, Venue venue);
Symbols are created lazily, on the first market-data subscription for that instrument, by the
adapter's addSymbol(). Creation assigns a symid_t, attaches the shared FifoMatchingEngine for
that venue, and calls createOrderBook<OrderBookType::L2>().
Two properties are worth knowing:
- Keyed by name across all venues.
symbols_by_name_is a flatunordered_map<std::string, Symbol*>. If Coinbase and a future venue both list an instrument under the same string, the secondcreateSymbolinserts a duplicate name andgetSymbolreturns whichever the map kept — orders for one venue could route into the other's book. Today's two venues use disjoint naming (BTC-USDvsBTC), so it does not bite. - Pointers into a
std::vector.symbols_is astd::vector<Symbol>reserved for 512 entries, andcreateSymbolhands out&symbols_.back(). Past 512 symbols the vector reallocates and every previously returnedSymbol*dangles.
Startup and shutdown¶
sequenceDiagram
participant main
participant Exchange
participant Gateways
participant Publisher
participant Feed
main->>main: parse config JSON, set log level
loop for each enabled venue
main->>Exchange: construct (builds queues, gateways, publisher, feeds)
main->>Exchange: start()
Exchange->>Gateways: start() (one uWS thread each)
Exchange->>Publisher: start() (one uWS thread)
Exchange->>Feed: start() (subscribes upstream)
Exchange->>Exchange: spawn exchange thread
end
main->>main: sleep 1s in a loop until SIGINT
Note over main: Ctrl+C sets the run flag false
loop for each exchange
main->>Exchange: stop()
Exchange->>Gateways: stop() (close listen socket on loop thread, join)
Exchange->>Publisher: stop()
Exchange->>Exchange: join exchange thread
end
The Exchange constructor throws std::runtime_error if the venue's config is missing either
order_gateway or md_publisher; main does not catch it, so a malformed config terminates the
process. main exits with EXIT_FAILURE if the config file is absent, unparseable, or lacks a
top-level exchanges object.
Where to go next¶
- Order lifecycle — follow one order all the way through the pipeline above.
- Market data — follow one book update in the other direction.
- Known gaps — what in this picture is aspirational.