WebSocket
One socket per client, four in-process projections, and the exact-match filter that bites everyone once.
/v1/stream — one connection per client. The BFF holds a single SSE connection to the engine
(/v1/admin/events/stream, bearer ENGINE_ADMIN_TOKEN) and fans it out after filtering.
Four in-process projections recompose the raw firehose into domain frames: positions, balance, trades, orderbook snapshots, and OHLC candles.
Query params
All optional, CSV or repeated:
kinds=position.update,trade,candle.update
party=Alice::1220…,Bob::1220…
markets=BTC-USDCX
intervals=1m,5m # narrows candle.* frames onlymarkets= is compared exactly. A wrong dialect (BTC/USDCx) matches nothing — and an
unmatched filter subscribes you to every market rather than none. The symptom is a
suspiciously busy tape, not an error.
Bootstrapping
REST for the snapshot, WS for the deltas:
| Snapshot (REST) | Frame (WS) |
|---|---|
GET /v1/users/:party/positions | position.update |
GET /v1/users/:party/balance | balance.update |
GET /v1/orderbook?depth= | orderbook.snapshot |
| — (direct WS) | trade |
| — (live only) | candle.update / candle.close |
Frame envelope
{
"kind": "trade",
"ts": "2026-05-27T13:42:01.123Z",
"seq": 12345,
"market": "BTC-USDCX",
"payload": { "...": "kind-specific" }
}Domain frames
| Kind | Derived from | Filterable by |
|---|---|---|
position.update | engine.order_filled per leg, scoped to userParty | party, markets |
balance.update | engine.order_filled per leg | party |
trade | engine.order_filled, taker leg only, flip legs coalesced | markets |
orderbook.snapshot | Poll of engine.orderbook(depth) every WS_ORDERBOOK_POLL_MS, hash-skipped if unchanged | markets |
candle.update | In-memory OHLCV bucket, updated per taker fill | markets, intervals |
candle.close | Emitted once when the next bucket opens | markets, intervals |
position.update
{
"kind": "position.update",
"seq": 12345,
"ts": "2026-05-27T13:42:01.123Z",
"userParty": "Alice::1220…",
"market": "BTC-USDCX",
"payload": {
"positionId": "pos-1f0e9d8c7b6a5f4e3d2c1b0a99887766",
"size": "0.5000000000",
"entryPrice": "65010.0000000000",
"realizedPnl": "0.0000000000",
"asOfSeq": 12345
}
}positionId is an epoch identity. A flip closes one epoch and opens another, so you receive
two frames: size: "0" on the old id, then a reopen under a fresh id with realizedPnl
restarting at zero. Keying positions by market instead of by positionId silently merges the
two and reports wrong realised PnL.
trade (public — no userParty)
{
"kind": "trade",
"seq": 12345,
"market": "BTC-USDCX",
"payload": {
"side": "buy",
"size": "0.5000000000",
"price": "65010.0000000000",
"takerOrderId": "o1",
"makerOrderId": "o0"
}
}orderbook.snapshot
{
"kind": "orderbook.snapshot",
"market": "BTC-USDCX",
"payload": {
"depth": 50,
"bids": [["65000.0000000000", "1.2500000000"]],
"asks": [["65010.0000000000", "0.5000000000"]]
}
}Snapshots, not deltas — and hash-skipped when unchanged, so a quiet book costs nothing.
candle.update / candle.close
{
"kind": "candle.update",
"market": "BTC-USDCX",
"interval": "1m",
"payload": {
"openTs": "2026-05-27T13:42:00.000Z",
"closeTs": "2026-05-27T13:43:00.000Z",
"open": "65010.0000000000",
"high": "65020.0000000000",
"low": "65005.0000000000",
"close": "65015.0000000000",
"volume": "1.2500000000",
"trades": 4
}
}candle.close fires once per bucket, when the next one opens — that is the signal to commit
the bar to history.
Engine WAL passthroughs
Raw events forwarded unchanged, for tooling and debugging:
| Kind | Meaning |
|---|---|
engine.tick_start | Matching cycle began |
inbound.deposit | Deposit observed on-chain |
inbound.withdrawal_request | Withdrawal requested |
engine.approve_submitted / engine.reject_submitted | Withdrawal decision |
session.authorized | New session signed |
inbound.order | Order reached the engine |
engine.order_placed / _filled / _cancelled / _rejected | Raw order lifecycle |
engine.trigger_set / _cancelled / _fired | TP/SL lifecycle |
engine.attached_resolved | Attached tp/sl leg armed or abandoned (engine-internal, no leaf) |
engine.user_batch_committed | New per-user merkle commitment |
outbound.batch_committed | New global manifest |
Client pattern
const ws = new WebSocket(
streamWsUrl({
kinds: ['orderbook.snapshot', 'trade', 'candle.update', 'candle.close'],
markets: [market.id], // exact engine symbol
intervals: ['1m'],
party,
}),
);
ws.onmessage = (e) => {
const frame = JSON.parse(e.data);
switch (frame.kind) {
case 'orderbook.snapshot': replaceBook(frame.payload); break;
case 'trade': appendTape(frame.payload); break;
case 'candle.update': updateBar(frame.payload); break;
case 'candle.close': commitBar(frame.payload); break;
}
};On reconnect, do not replay from zero. Take the last seq you processed and backfill with
GET /v1/events?since=<seq> before resuming the socket.