# WebSocket feed

Serves Kalshi's WebSocket channels at the real path, reads back every frame the simulator rendered and cuts connections on demand.

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.

```bash
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`, `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).

```json
{"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:

```json
{"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:

```bash
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.

---

Source: https://www.rystic.ai/docs/simulators/kalshi/features/websocket-feed · Markdown: https://www.rystic.ai/docs/simulators/kalshi/features/websocket-feed.md
