Skip to Content
SimulatorsKalshiWebSocket feed

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 channelFrames arrive with typeNotes
orderbook_deltaorderbook_snapshot, then orderbook_deltaa fresh subscription is sent a snapshot first, your resting orders included
tickertickermarket data
tradetrademarket data
fillfillprivate: scoped to the connecting actor
market_positionsmarket_position (singular)private
user_ordersuser_order (singular)private
market_lifecycle_v2market_lifecycle_v2, event_lifecyclemarkets the simulator mints and the ones you POST /_settle; no ticker filter
multivariate_market_lifecyclemultivariate_market_lifecyclelive-fed simulator only
pyth_valuepyth_valuelive-fed only; scoped by underlying_tickers
cfbenchmarks_valuecfbenchmarks_valuelive-fed only; scoped by index_ids
communicationsnone yetthe subscription is accepted
order_group_updatesnone yetthe 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, unsubscribe and update_subscription.
    • The first reply to each subscribe is a {"type":"subscribed", …} confirmation. It carries the sid that channel’s frames arrive on.
    • Messages are {type, sid[, seq], msg}.
    • The server sends a heartbeat every 10 seconds.
  • 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 a ticker update when the quote moved. Applying a scenario goes through reset and re-opens with a fresh orderbook_snapshot. Tape movement prints no synthetic trade frames, because nothing exogenous crosses.
  • A seed that moves a book arrives as frames too. After boot, a POST /_rystic/seed or a timeline seed that changes an order book sends that change as orderbook_delta frames, and one that moves a market’s quote sends its ticker frame. A boot seed sends none.
  • market_lifecycle_v2 carries only what the simulator causes: created when it mints a market (from a tape or a seed), and determined then settled when you call POST /_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_id never 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}
  1. payload.msg is the frame body, so assert on that. The gateway numbers each subscription separately, so a client’s sid and seq differ from the ones recorded here.
  2. 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 on owner. Public market data has none.
  3. The log keeps 100,000 frames by default, which is minutes of a dense replay tape. Check dropped before 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]}.

  1. Select by tickers (any socket whose subscription names one; an unfiltered subscription names nothing), by connections (ids from GET /_rystic/ws) or all. A selector that matches nothing returns {"dropped":[]}.
  2. The default cut is a bare close with no close frame, which is what a crashed server or a severed path looks like. graceful: true sends 1001 going away first.
  3. Sockets are split by ticker, so drop one stream and assert the others survive: GET /_rystic/ws afterward lists what is still open.
  4. The drop shows on the event stream (rystic tail) as a control op.

WebSocket drops are on Pro and Enterprise.

Last updated on