Simulators
WebSocket feed
The simulator serves Kalshi’s WebSocket surface at /trade-api/ws/v2, so a streaming client connects unchanged and receives the pushes your own orders cause. Point your WebSocket base URL at ws://localhost:8080 (your $RYSTIC_URL with the scheme swapped), and sign the upgrade with any key: the key id is your account, as the main page’s Auth section explains. The simulator also reads back what it sent and cuts connections on your command, which a test uses to prove a bot reconnects.
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name ws --scenario empty_book --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/ws"
# -> {"connections":[]}
curl -s -X POST "$RYSTIC_URL/_rystic/ws-drop" -d '{"all":true}'
# -> {"dropped":[]}
curl -s -X POST "$RYSTIC_URL/trade-api/v2/portfolio/events/orders" -H 'Content-Type: application/json' -d '{"ticker":"KXDEMO-26-A","client_order_id":"q1","side":"bid","count":"1","price":"0.10","time_in_force":"good_till_canceled","self_trade_prevention_type":"taker_at_cross"}' | jq -c '{order_id, fill_count}'
# -> {"order_id":"a7cade2e-8c1a-43f4-8000-000000000001","fill_count":"0.00"}
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '.events[] | select(.type == "user_order") | .payload.msg | {order_id, status, remaining_count_fp}'
# -> {"order_id":"a7cade2e-8c1a-43f4-8000-000000000001","status":"resting","remaining_count_fp":"1.00"}
Channels and frame types
The channel you subscribe to is not always the type you receive. Two channels are plural and deliver singular frames, so a client that subscribes to user_orders and waits for a frame named user_orders waits forever.
| Subscribe to channel | Frames arrive with type | Notes |
|---|---|---|
orderbook_delta | orderbook_snapshot, then orderbook_delta | a fresh subscription is sent a snapshot first, your resting orders included |
ticker | ticker | market data |
trade | trade | market data |
fill | fill | private: scoped to the connecting actor |
market_positions | market_position (singular) | private |
user_orders | user_order (singular) | private |
market_lifecycle_v2 | market_lifecycle_v2, event_lifecycle | markets the simulator mints and the ones you POST /_settle; no ticker filter |
multivariate_market_lifecycle | multivariate_market_lifecycle | live-fed simulator only |
pyth_value | pyth_value | live-fed only; scoped by underlying_tickers |
cfbenchmarks_value | cfbenchmarks_value | live-fed only; scoped by index_ids |
communications | none yet | the subscription is accepted |
order_group_updates | none yet | the subscription is accepted |
Differs from Kalshi: the communications and order_group_updates channels accept a subscription but send no frames. Kalshi sends RFQ, quote and order group updates on them.
Differs from Kalshi: off the live feed, multivariate_market_lifecycle, pyth_value and cfbenchmarks_value accept a subscription but send no frames.
- The protocol matches the live feed. Field sets match the live demo feed, checked against live-captured traces.
- Commands are
subscribe,unsubscribeandupdate_subscription. - The first reply to each
subscribeis a{"type":"subscribed", …}confirmation. It carries thesidthat channel’s frames arrive on. - Messages are
{type, sid[, seq], msg}. - The server sends a
heartbeatevery 10 seconds.
- Commands are
- A connection replies to its commands in the order sent. A channel with no snapshot, such as
market_lifecycle_v2, is confirmed at once, even while a replay is busy or never sends a frame on it. - Subscribing again to a market-filtered channel you already hold does not error. It merges into that
sid. The reply is{"type":"ok","sid":…,"seq":…,"msg":{"market_tickers":[…]}}with the combined market list, and the added markets’ snapshots arrive, as on the live feed. - A moving scenario or tape arrives as incremental frames. Each tape frame is an
orderbook_delta(the frame’s book diff) plus atickerupdate when the quote moved. Applying a scenario goes through reset and re-opens with a freshorderbook_snapshot. Tape movement prints no synthetictradeframes, because nothing exogenous crosses. - A seed that moves a book arrives as frames too. After boot, a
POST /_rystic/seedor a timeline seed that changes an order book sends that change asorderbook_deltaframes, and one that moves a market’s quote sends itstickerframe. A boot seed sends none. market_lifecycle_v2carries only what the simulator causes:createdwhen it mints a market (from a tape or a seed), anddeterminedthensettledwhen you callPOST /_settle. A scheduled close sends nothing, as on the live feed.- Private frames follow the actor. The upgrade resolves its account the way REST does, and another actor’s
order_idnever leaks into your frames. - The feed is checked by hand. The automated comparison with Kalshi has no WebSocket tests. The feed’s control frames and the re-subscribe merge were checked by hand against Kalshi’s demo exchange.
The egress log
GET /_rystic/egress returns every frame the simulator rendered, in order: what it emitted, not what a subscriber received. Assert on it without opening a socket. ?tail=N returns the last N frames and POST /_rystic/egress-retain with {"retain":<n>} bounds the log (0 is unbounded).
{"events":[{"seq":78552,"effect":"eff_ws_trade","type":"trade",
"payload":{"type":"trade","sid":1,"seq":6380,
"msg":{"market_ticker":"T-1","trade_id":"trade_6380","taker_side":"yes",
"yes_price_dollars":"0.3600","count_fp":"27.74","ts":1785727219}}}],
"total":78552,"dropped":0}
payload.msgis the frame body, so assert on that. The gateway numbers each subscription separately, so a client’ssidandseqdiffer from the ones recorded here.- The log is not actor-scoped. A private frame carries
owner, and the gateway delivers it to that account alone, but this endpoint returns every frame. A two-bot run sees both bots’ fills, so filter onowner. Public market data has none. - The log keeps 100,000 frames by default, which is minutes of a dense replay tape. Check
droppedbefore you trust it as complete.
Dropping a connection on purpose
A reconnect bug in a one-to-many exchange client is fleet-wide, so the simulator cuts sockets on demand. GET /_rystic/ws lists every open socket with its subscriptions:
{"connections":[{"id":1,"actor":"member","remote":"127.0.0.1:52110","opened_at":"…",
"subscriptions":[{"sid":1,"channel":"orderbook_delta","tickers":["KXBTC15M-26SEP030630-30"]}]}]}
POST /_rystic/ws-drop cuts them:
curl -s -X POST "$RYSTIC_URL/_rystic/ws-drop" -d '{"tickers":["KXBTC15M-26SEP030630-30"]}'
# -> {"dropped":[]}
curl -s -X POST "$RYSTIC_URL/_rystic/ws-drop" -d '{"connections":[2,3]}'
# -> {"dropped":[]}
curl -s -X POST "$RYSTIC_URL/_rystic/ws-drop" -d '{"all":true,"graceful":true}'
# -> {"dropped":[]}
With a socket open, each call returns the ids it cut, for example {"dropped":[1]}.
- Select by
tickers(any socket whose subscription names one; an unfiltered subscription names nothing), byconnections(ids fromGET /_rystic/ws) orall. A selector that matches nothing returns{"dropped":[]}. - The default cut is a bare close with no close frame, which is what a crashed server or a severed path looks like.
graceful: truesends1001 going awayfirst. - Sockets are split by ticker, so drop one stream and assert the others survive:
GET /_rystic/wsafterward lists what is still open. - The drop shows on the event stream (
rystic tail) as a control op.
WebSocket drops are on Pro and Enterprise.