fastmm-sim-exchange¶
fastmm-sim-exchange is a Binance Spot-compatible simulated exchange (ADR-0008). The unmodified Binance connector (fastmm::venues::binance::BinanceVenue, see venues.md) and fastmm-live run against it on localhost over TCP or TLS: REST, market-data WebSockets, the WebSocket API, depth sequence sync, HMAC and Ed25519 authentication, order flow with fills, and fault injection.
fastmm-sim-exchange: one net::Reactor thread
├─ HttpServer<PlainStream> REST /api/v3/*, WebSocket :9080
├─ HttpServer<TlsStream<PlainStream>> the same over TLS :9443
├─ MatchingEngine price-time, LIMIT_MAKER rejection, STP, update ids
├─ MarketGenerator counterparty flow: random-walk mid, Poisson orders
├─ MdAggregator depthUpdate U/u batches, bookTicker
└─ venue state balances, order index, listen keys, rate limits
The TLS server uses the test fixture certificate. MarketGenerator places Poisson limit orders, market orders and cancels; MdAggregator publishes depth updates every depth_update_ms.
Code: include/fastmm/sim/server/ and src/sim/server/ (target fastmm::sim_server), app apps/fastmm-sim-exchange/.
Running¶
./build/release/bin/fastmm-sim-exchange --config configs/sim.toml # :9080, TLS :9443
FASTMM_SIM_API_KEY=sim-key FASTMM_SIM_API_SECRET=sim-secret \
./build/release/bin/fastmm-live --config configs/sim-local.toml
configs/sim-local-tls.toml connects over TLS instead. scripts/run-sim.sh runs both and prints a summary; docker compose -f deploy/docker/compose.yml up --build runs them in containers with configs/sim-docker.toml:
./scripts/run-sim.sh --duration 30s [--tls] [--build-dir build/<dir>]
docker compose -f deploy/docker/compose.yml up --build
Flags and exit codes: Command lines. Every --stats-interval the simulator prints a line with connections, orders, rejects, cancels, replaces, fills, open orders, public trades, depth diffs, tickers, snapshots, REST and WS API requests, rate-limited requests, authentication errors, the account position and the touch.
scripts/run-sim.sh takes --duration, --preset, --build-dir, --config (the engine's, default configs/sim-local.toml), --sim-config (the simulator's, default configs/sim.toml), --tls, --port and --tls-port. It starts the simulator, waits until its port accepts connections, runs fastmm-live --duration <t> --journal runs/<ts>/session.fmj --log runs/<ts>/engine.log, prints the engine, venue and simulator summaries, and stops the simulator. --port and --tls-port (or FASTMM_SIM_PORT and FASTMM_SIM_TLS_PORT) move the simulator; the engine then runs with a copy of its configuration that uses them. The key and secret come from FASTMM_SIM_API_KEY / FASTMM_SIM_API_SECRET (default sim-key / sim-secret); the engine configs read them, and in the simulator they override [sim.account].
Configuration (configs/sim.toml)¶
The simulator reads [[instruments]] (symbol, base, quote, tick, lot, min_qty, max_qty, min_notional; disabled ones are skipped), [sim] and [engine] net_backend. configs/sim.toml declares [venues.sim] kind = "sim" (an alias of binance_spot) only so that the instruments validate.
| key | default | meaning |
|---|---|---|
seed |
7 | generator seed |
venue |
all | simulate only the instruments of this venue |
start_mid, symbols.<SYM>.start_mid |
60000 | initial latent mid |
symbols.<SYM>.max_notional |
none | NOTIONAL filter maximum |
bind, port, tls_port, tls_cert, tls_key |
127.0.0.1, 9080, 9443, fixture | listeners |
depth_update_ms |
100 | depthUpdate aggregation window |
depth_snapshot |
live |
GET /api/v3/depth from the live book, or flushed (book as of the last published batch) |
driver_tick_ms |
2 | generator / aggregator poll period |
clock_offset_ms |
0 | server clock = host wall clock + offset |
start_time_ms |
0 | fixed epoch for reproducible timestamps (see Determinism) |
ping_interval_ms, pong_timeout_ms |
20000, 60000 | server-initiated WebSocket pings |
listen_key_validity_ms |
3600000 | legacy listenKey lifetime |
account.api_key, account.api_secret |
sim-key, sim-secret | the HMAC key (env overrides) |
account.ed25519_public_key_file |
none | Ed25519 public key (PEM): the account key becomes an Ed25519 key, session.logon works |
account.balances.<ASSET> |
100 base, 10,000,000 quote | starting balances |
fees.maker_bps, fees.taker_bps |
1, 4 | commission, charged in the quote asset |
limits.weight_per_minute |
6000 | REQUEST_WEIGHT / 1 MINUTE |
limits.orders_per_10s, limits.orders_per_day |
1000, 1000000 | ORDERS limits (Binance: 50 / 10 s) |
limits.max_recv_window_ms |
60000 | larger recvWindow answers -1131 |
generator.* |
see file | MarketGeneratorParams + enabled, seed_levels |
faults.* |
off | see Fault injection |
The default generator places its touch 2990 ticks (29.90 USDT) from a slowly moving latent mid, so BasicMM's 5 bps quotes (30 USDT at 60000) rest behind the best generator levels and are filled by the larger market orders. The book is not realistic.
What is implemented¶
Request and response formats are those BinanceVenue uses (What a Binance-compatible simulator must implement).
REST¶
Query string or form body; the signature covers query + body.
| endpoint | notes |
|---|---|
GET /api/v3/ping, GET /api/v3/time |
|
GET /api/v3/exchangeInfo[?symbol= / ?symbols=[..]] |
rateLimits, PRICE_FILTER, LOT_SIZE, NOTIONAL, MAX_NUM_ORDERS |
GET /api/v3/depth?symbol&limit |
limit 1..5000, weight 5/25/50/250 |
GET /api/v3/ticker/bookTicker?symbol |
|
GET /api/v3/account |
balances |
POST /api/v3/order, POST /api/v3/order/test |
LIMIT (GTC/IOC/FOK), LIMIT_MAKER, MARKET; ACK/RESULT/FULL |
DELETE /api/v3/order, GET /api/v3/order |
by orderId or origClientOrderId |
POST /api/v3/order/cancelReplace |
STOP_ON_FAILURE and ALLOW_FAILURE, -2021/-2022 with data |
PUT /api/v3/order/amend/keepPriority |
quantity decrease, keeps queue priority |
GET /api/v3/openOrders[?symbol], DELETE /api/v3/openOrders?symbol |
-2011 when nothing is open |
GET /api/v3/account/commission?symbol |
fees.maker_bps and fees.taker_bps as standardCommission; special and tax commission zero |
GET /api/v3/myTrades?symbol[&fromId / &startTime&endTime][&limit] |
the account's executions, oldest id first; fromId with a time range is -1128, a window over 24 h is -1127, limit caps at 1000 |
POST/PUT/DELETE /api/v3/userDataStream |
legacy listenKey (/ws/<listenKey>) |
Every response carries X-MBX-USED-WEIGHT-1M. Order endpoints also carry X-MBX-ORDER-COUNT-10S and X-MBX-ORDER-COUNT-1D. 429 responses carry Retry-After.
Market-data WebSockets¶
/stream?streams=a/b/c sends {"stream","data"}. /ws/<stream> (several streams separated by /) and /ws send raw payloads, and both accept SUBSCRIBE / UNSUBSCRIBE / LIST_SUBSCRIPTIONS. Streams: <sym>@depth, <sym>@depth@100ms and <sym>@depth@1000ms all use the same depth_update_ms batches. <sym>@bookTicker is sent when the touch changed during a batch; <sym>@trade (which adds M) is sent immediately. U/u are the matching engine's per-symbol update ids, so the stream is contiguous and a REST snapshot's lastUpdateId is consistent with it.
WebSocket API¶
/ws-api/v3 echoes the request id verbatim. Methods: ping, time, exchangeInfo, depth, ticker.book, userDataStream.subscribe.signature (returns {"subscriptionId":N}), session.logon / session.status / session.logout and userDataStream.subscribe (Ed25519 account), userDataStream.unsubscribe, order.place, order.test, order.cancel, order.cancelReplace, order.amend.keepPriority, order.status, openOrders.status, openOrders.cancelAll and account.status. A 429 error carries data.retryAfter.
User data events¶
Events go to WS API connections with a user-data subscription as {"subscriptionId":N,"event":{...}}, and raw to /ws/<listenKey> connections:
executionReport.xisNEW,CANCELED(c= cancel request id,C= cancelled order id),REPLACED(amend),REJECTED(post-acceptance),TRADE(l,L,n,N,t,m),EXPIRED(IOC/FOK/MARKET remainder) orTRADE_PREVENTION(self-trade, EXPIRE_MAKER).outboundAccountPositionafter every balance change. Events from one request are sent after its response.listenKeyExpired.
Trading model¶
LIMIT / LIMIT_MAKER orders lock quote notional (buys) or base quantity (sells). A fill releases the lock and moves balances, with commission in the quote asset. Rejections: -1013 Filter failure: PRICE_FILTER / LOT_SIZE / NOTIONAL; -2010 for a duplicate client id (Duplicate order sent.), more than 200 open orders on the symbol (Filter failure: MAX_NUM_ORDERS), insufficient balance, and a crossing LIMIT_MAKER (Order would immediately match and take.).
Authentication and limits¶
| condition | answer |
|---|---|
| missing API key / unknown key | 401 -2014 / 401 -2015 |
missing timestamp or signature |
400 -1102 |
| bad signature | 400 -1022 Signature for this request is not valid. |
timestamp >= serverTime + 1000 |
400 -1021 ... 1000ms ahead of the server's time. |
serverTime - timestamp > recvWindow |
400 -1021 Timestamp for this request is outside of the recvWindow. |
recvWindow > max_recv_window_ms |
400 -1131 |
| weight window exceeded | 429 -1003 + Retry-After |
| order-count window exceeded | 429 -1015 + Retry-After |
| unknown order on cancel / query / amend | 400 -2011 Unknown order sent. / -2013 Order does not exist. |
The server also sends WebSocket pings every ping_interval_ms and closes connections that have not answered for pong_timeout_ms.
Fault injection¶
The timed [sim.faults] settings (*_after_s) are one-shot, in seconds after the listeners open, 0 = off. The SimExchangeServer methods may be called from any thread.
| config key | API | what it exercises |
|---|---|---|
drop_md_after_s |
drop_market_data_connections() |
md Disconnected, quotes pulled, reconnect, re-snapshot |
drop_ws_api_after_s |
drop_ws_api_connections(include_user_streams) |
order-channel loss, REST cancel-all, reconnect |
skip_depth_update_after_s |
skip_next_depth_update() |
U != prev_u + 1, Resyncing, re-snapshot |
timestamp_error_after_s |
fail_next_timestamp() |
-1021 handling, server-time resync |
rest_unresponsive_after_s / _for_s |
set_rest_unresponsive(bool) |
REST timeouts (requests are swallowed) |
delay_ack_ms |
set_ack_delay_ms(ms) |
WS API order responses and their user events arrive late |
reject_next_orders |
reject_next_orders(k) |
-2010 rejects |
rate_limit_next |
rate_limit_next_requests(k) |
429 / -1003 / Retry-After cooldown |
| — | set_clock_offset_ms(ms) |
clock skew between client and venue |
| — | expire_listen_keys() |
legacy listenKey expiry |
| — | swallow_next_ws_api_responses(k) |
the request is carried out and the reply never sent: an outcome the client cannot know |
| — | duplicate_next_user_events(k) |
a repeated executionReport: duplicate acks, duplicate fills |
| — | set_user_stream_muted(bool) |
the private stream is out without the connection closing; nothing replays what was missed |
| — | fill_open_order(id, qty) |
crosses one named resting order (everything ahead of it in price-time is swept first), returns what it filled |
| — | ban_next_requests(k) |
418 + Retry-After, as an IP ban |
| — | fail_next_auth(k) |
401 / -2015, as a revoked key |
| — | send_malformed_frames(md, ws_api) |
one text frame that is not JSON on every open connection |
open_client_order_ids() lists what the account holds open, for comparing the two sides' views.
stats() returns counters for all of the above, plus counts since mark(): minimum open orders, orders, cancels, fills, cancel-alls, open-order and myTrades queries, and sessions opened. duplicate_client_order_ids counts ids accepted twice in the run, after the first order ended; a restarted engine that reused a session epoch shows up there.
tests/integration/recovery_test.cpp drives all of them.
Determinism¶
The generator, order ids and trade ids depend only on the configuration, the seed and the order of requests. Event times inside the matching engine come from the reactor clock. Published timestamps (E, T, transactTime, serverTime) and recvWindow checks follow the host wall clock plus clock_offset_ms; on WSL2 the wall clock steps by more than a second. With start_time_ms set, published times are start_time + elapsed and reproducible, but clients then rely on their measured clock offset.
Differences from real Binance¶
Not implemented:
- RSA keys and more than one account. With
[sim.account] ed25519_public_key_file(a-----BEGIN PUBLIC KEY-----PEM) the account key is Ed25519: signatures are verified with it,session.logon,session.status,session.logoutand the unsigneduserDataStream.subscribework, and later requests on that connection may omitapiKeyandsignature.session.logonwith an HMAC account answers-4056. Session revocation is not simulated. - SBE market data and
responseFormat=sbe: JSON only. - Order types other than LIMIT / LIMIT_MAKER / MARKET: no stop, take-profit, iceberg, trailing,
quoteOrderQty, OCO/OTO/OPO lists, SOR or pegged orders (-1014). - Other market data: klines, aggTrades, 24 h tickers, avgPrice,
@depth<N>partial books,/api/v3/trades.@depthand@depth@100msshare one interval, and bookTicker is batched per interval rather than sent on every change. - The events
balanceUpdate,listStatus,externalLockUpdateandeventStreamTerminated. - Self-trade prevention is EXPIRE_MAKER only. Commission is always charged in the quote asset. There is no BNB discount.
- Rate limits are not per IP: one weight window for the whole server and one order-count window for the account. A 418 ban only happens when a test asks for one (
ban_next_requests), and there is no connection weight and noX-MBX-ORDER-COUNT-*on the WS API (counts are inrateLimits). - The legacy listenKey stream is kept (venues.md).
- The ack delay applies to WS API responses only. REST answers are synchronous.
- Order ids start at 1 for every run. Cancelled and filled orders are forgotten, so querying them answers
-2013. Their executions are kept:myTradesanswers from a trade log that lives for the whole run. myTradescharges IP weight 20 (5 withorderId, which the connector does not send),commissionAssetis always the quote asset, and trade ids are the matching engine's, so they are shared with the public trade stream and skip over the generator's own matches.
Integration tests¶
tests/integration (label integration) runs BinanceVenue and the fastmm-live wiring against the in-process server over TCP and TLS; FASTMM_IT_LOG=1 prints the connector and engine log. recovery_test.cpp and recovery_restart_test.cpp break a session with the fault controls above and check it recovers; the latter kills and restarts real fastmm-live processes.