# Kalshi simulator

A local Kalshi — real matching engine, WebSocket feed, captured-day replay.

`kalshi-twin` is a local, deterministic Kalshi — Trade API v2, the WebSocket feed, and a real price-time-priority matching engine, validated against Kalshi's demo exchange.

## Quickstart

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…     # an API key from https://www.rystic.ai/account/api-keys
rystic run kalshi-twin -d --port 0 --scenario btc15m_tape   # prints the URL it chose
curl -s http://localhost:<port>/_rystic/version
```

Expect `"product":"kalshi-twin"` and `"version":"0.0.49"`. `btc15m_tape` is a real KXBTC15M market recorded off the exchange feed, replayed event for event on the exchange clock, rebased to now and looping. Enterprise signs in with its license file instead: `rystic login ./rystic-license.json` ([Enterprise license files](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)). Plain `docker pull` and Compose: [Docker](https://www.rystic.ai/docs/docker.md).

The examples on this page use `B` for the URL `rystic run` printed. The market appears on the tape's first frame, a moment after boot:

```bash
B=http://localhost:8080      # yours may differ
curl -s $B/trade-api/v2/markets | jq -r '.markets[].ticker'
# KXBTC15M-26SEP030630-30
curl -s $B/trade-api/v2/markets/KXBTC15M-26SEP030630-30/orderbook | jq -c '.orderbook_fp.yes_dollars[-3:]'
curl -s -X POST $B/trade-api/v2/portfolio/events/orders -H 'Content-Type: application/json' \
  -d '{"ticker":"KXBTC15M-26SEP030630-30","client_order_id":"q1","side":"bid","count":"1",
       "price":"0.10","time_in_force":"good_till_canceled","self_trade_prevention_type":"taker_at_cross"}'
# -> 201 {"order_id":…,"fill_count":"0.00",…}; GET /trade-api/v2/portfolio/orders/<order_id> shows status "resting"
```

That is the V2 order route, the one live Kalshi serves. The V1 writes (`POST /portfolio/orders`, its amend, `DELETE /portfolio/orders/{id}`, `DELETE /portfolio/orders/batched`) have answered `410 deprecated_v1_order_endpoint` on live since June 2026, and the simulator answers the same. A bot you cannot change yet keeps the old wire when the simulator starts with `RYSTIC_KALSHI_LEGACY_ORDERS=on`.

Then the sample bot that ships with the simulator, 40 arbitrary orders against the tape's market:

```bash
python3 ~/.rystic/twins/kalshi-twin/0.0.49/random-walker.py $B   # $RYSTIC_HOME if you set one; the URL argument is the twin
```

```
40 orders -> 201×25, 400×8, 409×7
the twin coped: every order answered, account and book still read
```

The split into accepted, malformed and refused is the point — a simulator that took all 40 would not be modeling an exchange. On a moving tape the counts vary run to run; on a static scenario such as `tight_book` they repeat exactly. `rystic stop default` stops it.

## Plans

`kalshi-twin` is on every plan ([plans](https://www.rystic.ai/docs/licensing.md#plans)). Every plan serves all 113 Trade API routes, the WebSocket feed, the matching engine, [`POST /_settle`](#settling-a-market), all 47 scenarios and the `/_rystic/` and `/_replay/` routes: seeding, the clock and RNG, inspect and egress, the state UI, replay, live mode and batch. Network faults, WebSocket drops and the verified Kalshi UI are Pro and Enterprise. Without `--scenario` the simulator boots **empty** — `GET /trade-api/v2/markets` answers `{"cursor":"","markets":[]}` and the sample bots exit 1 with `no markets in the twin's world` — and `POST /_rystic/reset` wipes it to empty.

## Smoke-test a trade

```bash
cd ~/.rystic/twins/kalshi-twin/*/    # or name the version `rystic list` shows
bash check.sh                        # boots a twin and reads a market back
bash trade-check.sh                  # pass host:port as $1 if not :8080
```

`check.sh` boots its own simulator on your API key (or Enterprise license), seeds a market and reads it back. `trade-check.sh` seeds a book, crosses it fill-or-kill, and asserts fill, position, depletion and WS egress. It calls `/_rystic/reset` and seeds its own book, so the running scenario, balances, orders and egress are gone afterwards; re-POST your scenario. `HOWTO.md`, `SCENARIOS.md` and `scenarios/` sit beside them in the archive; the container image carries `HOWTO.md` and the scripts only.

Point your bot at it — configuration only, no code changes. The Trade API path stays: `https://api.elections.kalshi.com/trade-api/v2` becomes `http://localhost:8080/trade-api/v2`, so a client that builds URLs from a base needs that suffix (`KALSHI_API_BASE=http://localhost:8080/trade-api/v2`). The config below is an example from a bot that appends the path itself and keeps REST and WebSocket settings apart; map each key to your own bot's names:

```json
{
  "HTTP_BASE_URL": "http://localhost:8080",
  "WS_BASE_URL": "ws://localhost:8080",
  "REQUEST_KEY_ID": "member",
  "REQUEST_PEM_KEY": "<contents of twin_key.pem — see Auth>",
  "STREAMING_KEY_ID": "member",
  "STREAMING_PEM_KEY": "<contents of twin_key.pem — see Auth>",
  "SUBACCOUNT": 0
}
```

1. `HTTP_BASE_URL` and `WS_BASE_URL` are the simulator's address for REST and the WebSocket feed. `REQUEST_*` sign REST calls and `STREAMING_*` the WebSocket upgrade; any RSA key works (see **Auth**). `SUBACCOUNT` is Kalshi's subaccount number, `0` the primary.
2. Supply the credentials directly. A bot that otherwise loads keys from a cloud secret store (for example AWS SSM through `boto3`) then needs no cloud account.
3. The key id is an identity: `member` shares the default account with headerless curl. Any other id trades in its own portfolio — and a plain `curl` then reads `member`'s (see **Multiple bots**).
4. Nothing at `GET /trade-api/v2/portfolio/orders` means the bot isn't reaching the simulator.

## Auth

The simulator accepts any Kalshi credential and never validates a signature, but a Kalshi client needs a real RSA key to sign with. `check.sh` generates a throwaway `twin_key.pem` beside the binary when it can (it needs `openssl` and a writable directory), or make your own; any key id works.

```bash
openssl genrsa -out twin_key.pem 2048
```

The key id is your account identity — precedence and the traps in **Multiple bots**.

## API key and Enterprise license

The simulator needs a Rystic credential to start — separate from the Kalshi key above. `rystic run` hands it over; running the binary or a container yourself, you set it:

```bash
RYSTIC_TOKEN=rys_… ./kalshi-twin                           # Free and Pro: your API key
RYSTIC_LICENSE=/path/to/rystic-license.json ./kalshi-twin   # Enterprise: the license file
```

1. **Free and Pro** run on your API key: `rystic login rys_…` stores the key, and a container `rystic run` starts gets `RYSTIC_TOKEN`. The simulator checks in with `lease.rystic.ai` while it runs and stops when your plan's time runs out ([What stops a simulator](https://www.rystic.ai/docs/licensing.md#what-stops-a-simulator)). A Compose file you keep sets `RYSTIC_TOKEN: ${RYSTIC_TOKEN}` and mounts a `rystic-leases` volume, as the examples below do.
2. **Enterprise** runs on its license file: a license verifies offline, once at startup, so a serving simulator is never interrupted; it starts for 7 days past expiry with a warning, then refuses. A Compose file mounts the license read-only and points `RYSTIC_LICENSE` at the path — swap it in wherever the examples below say `RYSTIC_TOKEN`, and drop the lease volume. `GET /_rystic/version` reports the licensee and expiry.
3. `LICENSE.txt` governs use.

## Coverage

113 Trade API v2 routes in the current build, plus the WebSocket feed and the matching engine. `GET /_rystic/routes` lists every one, plus `POST /_settle`; the control plane is not among them. One route answers as demo does rather than acting: `DELETE /portfolio/events/orders`, the cancel-all kill switch, returns `404` and leaves your orders resting, so cancel by id or with `DELETE /portfolio/events/orders/batched`.

Query parameters, request bodies, response fields and cursor pagination are Kalshi's own: read them in [Kalshi's API reference](https://docs.kalshi.com). This page covers only where the simulator differs, under **Limitations**. The four V1 order writes, marked `(retired: 410)` in the table (`POST /portfolio/orders`, `POST /portfolio/orders/{order_id}/amend`, `DELETE /portfolio/orders/{order_id}`, `DELETE /portfolio/orders/batched`) answer `410 deprecated_v1_order_endpoint` unless the simulator starts with `RYSTIC_KALSHI_LEGACY_ORDERS=on`.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs kalshi; do not edit) -->

<!-- coverage-claim -->
113 routes, counted from the simulator's literal registrations.

| Resource | Operations |
|---|---|
| Account API limits (1) | `GET /trade-api/v2/account/limits` |
| Account API usage level volume progress (1) | `GET /trade-api/v2/account/api_usage_level/volume_progress` |
| Account balance (1) | `GET /trade-api/v2/portfolio/balance` |
| Account endpoint costs (1) | `GET /trade-api/v2/account/endpoint_costs` |
| API key (4) | `GET, POST /trade-api/v2/api_keys`; `POST /trade-api/v2/api_keys/generate`; `DELETE /trade-api/v2/api_keys/{api_key}` |
| API usage-level grant (1) | `POST /trade-api/v2/account/api_usage_level/upgrade` |
| Block Trade Proposal (3) | `GET, POST /trade-api/v2/communications/block-trade-proposals`; `POST /trade-api/v2/communications/block-trade-proposals/{block_trade_proposal_id}/accept` |
| Deposit (1) | `GET /trade-api/v2/portfolio/deposits` |
| Event (5) | `GET /trade-api/v2/events`; `GET /trade-api/v2/events/multivariate`; `GET /trade-api/v2/events/{event_ticker}`; `GET /trade-api/v2/series/{series_ticker}/events/{ticker}/candlesticks`; `GET /trade-api/v2/series/{series_ticker}/events/{ticker}/forecast_percentile_history` |
| Event fee change (1) | `GET /trade-api/v2/events/fee_changes` |
| Event live data (1) | `GET /trade-api/v2/live_data/events/{event_ticker}` |
| Event metadata (1) | `GET /trade-api/v2/events/{event_ticker}/metadata` |
| Exchange schedule (1) | `GET /trade-api/v2/exchange/schedule` |
| Exchange status (1) | `GET /trade-api/v2/exchange/status` |
| Fill (1) | `GET /trade-api/v2/portfolio/fills` |
| Historical data cutoff (1) | `GET /trade-api/v2/historical/cutoff` |
| Historical fill (1) | `GET /trade-api/v2/historical/fills` |
| Historical market (2) | `GET /trade-api/v2/historical/markets`; `GET /trade-api/v2/historical/markets/{ticker}` |
| Historical market candlestick (1) | `GET /trade-api/v2/historical/markets/{ticker}/candlesticks` |
| Historical order (1) | `GET /trade-api/v2/historical/orders` |
| Historical trade (1) | `GET /trade-api/v2/historical/trades` |
| Incentive program (1) | `GET /trade-api/v2/incentive_programs` |
| Intra exchange instance transfer (3) | `POST /trade-api/v2/portfolio/intra_exchange_instance_transfer`; `GET /trade-api/v2/portfolio/intra_exchange_instance_transfers`; `GET /trade-api/v2/portfolio/intra_exchange_instance_transfers/{transfer_id}` |
| Live data (3) | `GET /trade-api/v2/live_data/batch`; `GET /trade-api/v2/live_data/milestone/{milestone_id}`; `GET /trade-api/v2/live_data/weather/{city}` |
| Market (4) | `GET /trade-api/v2/markets`; `GET /trade-api/v2/markets/candlesticks`; `GET /trade-api/v2/markets/{ticker}`; `GET /trade-api/v2/series/{series_ticker}/markets/{ticker}/candlesticks` |
| Market position (3) | `GET /trade-api/v2/fcm/positions`; `GET /trade-api/v2/historical/positions`; `GET /trade-api/v2/portfolio/positions` |
| Milestone (4) | `GET /trade-api/v2/live_data/milestone/{milestone_id}/game_stats`; `GET /trade-api/v2/live_data/{type}/milestone/{milestone_id}`; `GET /trade-api/v2/milestones`; `GET /trade-api/v2/milestones/{milestone_id}` |
| Multivariate event collection (3) | `GET /trade-api/v2/multivariate_event_collections`; `GET, POST /trade-api/v2/multivariate_event_collections/{collection_ticker}` |
| Order (17) | `GET /trade-api/v2/fcm/orders`; `POST, DELETE /trade-api/v2/portfolio/events/orders`; `POST, DELETE /trade-api/v2/portfolio/events/orders/batched`; `DELETE /trade-api/v2/portfolio/events/orders/{order_id}`; `POST /trade-api/v2/portfolio/events/orders/{order_id}/amend`; `POST /trade-api/v2/portfolio/events/orders/{order_id}/decrease`; `GET /trade-api/v2/portfolio/orders`; `POST /trade-api/v2/portfolio/orders` (retired: 410); `DELETE /trade-api/v2/portfolio/orders/batched` (retired: 410); `GET /trade-api/v2/portfolio/orders/queue_positions`; `GET /trade-api/v2/portfolio/orders/{order_id}`; `DELETE /trade-api/v2/portfolio/orders/{order_id}` (retired: 410); `POST /trade-api/v2/portfolio/orders/{order_id}/amend` (retired: 410); `GET /trade-api/v2/portfolio/orders/{order_id}/queue_position`; `GET /trade-api/v2/portfolio/summary/total_resting_order_value` |
| Order group (7) | `GET /trade-api/v2/portfolio/order_groups`; `POST /trade-api/v2/portfolio/order_groups/create`; `GET, DELETE /trade-api/v2/portfolio/order_groups/{order_group_id}`; `PUT /trade-api/v2/portfolio/order_groups/{order_group_id}/limit`; `PUT /trade-api/v2/portfolio/order_groups/{order_group_id}/reset`; `PUT /trade-api/v2/portfolio/order_groups/{order_group_id}/trigger` |
| Orderbook (2) | `GET /trade-api/v2/markets/orderbooks`; `GET /trade-api/v2/markets/{ticker}/orderbook` |
| Quote (10) | `GET, POST /trade-api/v2/communications/quotes`; `GET, DELETE /trade-api/v2/communications/quotes/{quote_id}`; `PUT /trade-api/v2/communications/quotes/{quote_id}/accept`; `PUT /trade-api/v2/communications/quotes/{quote_id}/confirm`; `GET, DELETE /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}`; `PUT /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}/accept`; `PUT /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}/confirm` |
| RFQ (4) | `GET, POST /trade-api/v2/communications/rfqs`; `GET, DELETE /trade-api/v2/communications/rfqs/{rfq_id}` |
| Search category tags (1) | `GET /trade-api/v2/search/tags_by_categories` |
| Search sport filters (1) | `GET /trade-api/v2/search/filters_by_sport` |
| Series (2) | `GET /trade-api/v2/series`; `GET /trade-api/v2/series/{series_ticker}` |
| Series fee change (1) | `GET /trade-api/v2/series/fee_changes` |
| Settlement (1) | `GET /trade-api/v2/portfolio/settlements` |
| Structured target (2) | `GET /trade-api/v2/structured_targets`; `GET /trade-api/v2/structured_targets/{structured_target_id}` |
| Subaccount (1) | `POST /trade-api/v2/portfolio/subaccounts` |
| Subaccount balance (2) | `GET /trade-api/v2/portfolio/subaccounts/balances`; `POST /trade-api/v2/portfolio/subaccounts/positions/transfer` |
| Subaccount netting config (2) | `GET, PUT /trade-api/v2/portfolio/subaccounts/netting` |
| Subaccount transfer (2) | `POST /trade-api/v2/portfolio/subaccounts/transfer`; `GET /trade-api/v2/portfolio/subaccounts/transfers` |
| Target balance allocation (2) | `GET, POST /trade-api/v2/portfolio/target_balance_allocation` |
| Trade (1) | `GET /trade-api/v2/markets/trades` |
| User data timestamp (1) | `GET /trade-api/v2/exchange/user_data_timestamp` |
| Withdrawal (1) | `GET /trade-api/v2/portfolio/withdrawals` |
| Other (1) | `GET /trade-api/v2/communications/id` |

<!-- END generated: served-operations -->

Matching semantics — price-time priority, amend-loses-priority, decrease-keeps-priority, FOK/IOC, self-trade prevention, taker-fee math — verified against demo with live two-account tests.

## Control plane

Every route under `/_rystic/` is listed in [routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan). Kalshi adds two mounts in its own vocabulary, `/_settle` and `/_replay/`, and these routes carry Kalshi specifics:

| Route | What it does |
|---|---|
| `GET /_rystic/routes` | every Trade API route this build serves, plus `POST /_settle`; not the control plane |
| `POST /_settle` | resolve a market ([Settling a market](#settling-a-market)) |
| `GET /_rystic/egress` | WS frames your orders triggered, in order ([Egress log](#egress-log)) |
| `GET`, `POST /_rystic/network` | the network profile: per-endpoint latency (`netprof_kalshi_v1`, the latency profile measured against production Kalshi, off by default; [Make the simulator as slow as production](#make-the-simulator-as-slow-as-production)) and injected rejects ([Network profiles](https://www.rystic.ai/docs/control-plane.md#network-profiles-and-fault-injection)) |
| `GET /_rystic/ws` | open WebSocket connections and their subscriptions |
| `POST /_rystic/ws-drop` | cut connections by ticker, id, or all — see [WebSocket feed](#drop-a-connection-on-purpose) |
| `/_rystic/ui` | the [state UI](#state-ui) |
| `/_replay/` | `markets`, `prepare`, `start`, `play`, `pause`, `seek`, `status`, `stream`, `feed`, `watch` — [replay](#replay-a-captured-market-day) and [live mode](#live-mode); `prepare` and `stream` are under [replay](#replay-a-captured-market-day) |

## Scenarios

47 scenarios are compiled into the binary — no data files, no network.

| Category | Scenarios |
|---|---|
| Recorded | `btc15m_tape` — a real captured session, looping |
| Book shape (11) | `tight_book` `wide_spread` `thin_book` `empty_book` `bid_only` `ask_only` `deep_book` `gapped_book` `top_heavy_book` `long_shot` `near_certain` |
| Regimes (11, move on their own) | `trend_up` `trend_down` `chop` `gap_up` `flash_crash` `spread_blowout` `liquidity_evaporation` `drift_to_one` `drift_to_zero` `pinned_at_fifty` `stair_step` |
| Account and risk (6) | `broke` `rich` `holding_long` `holding_short` `underwater` `shard2_world` (a market on exchange shard 2 and an account funded there; see [Exchange shards](#exchange-shards)) |
| Conditions (5) | `exchange_halted` `market_closed` `market_unopened` `closes_mid_run` `rate_limited` |
| Faults (9) | `flaky_500` `brownout_503` `edge_5xx` `writes_throttled` `expired_auth` `forbidden_403` `degrades_mid_run` `session_expires_mid_run` `heals_after_outage` |
| Multi-market (3) | `dutch_book` `no_arb` `arb_closes` |
| Archive (1) | `has_history` — the `/historical/*` half |

The network-fault scenarios carry a network profile. The archive's `scenarios/` holds three arbitrage packs to boot with `--seed`; the container image does not carry them. [Scenarios](https://www.rystic.ai/docs/concepts/worlds.md) covers listing and loading scenarios.

## Seeding state

[Scenarios](https://www.rystic.ai/docs/concepts/worlds.md) covers seed files, `POST /_rystic/seed`, `GET /_rystic/registers` and the seed gate. A boot file funding `member`, with a pinned clock and a fixed RNG:

```json
{
  "seed": {
    "state_balance": { "member": { "balance": 1000000, "balance_dollars": "10000.0000",
      "portfolio_value": 0, "updated_ts": 1784989800 } }
  },
  "clock": "2026-07-23T00:00:00Z",
  "rng_seed": 42
}
```

**A record needs its whole required field set.** A short one is served as though whole, so it surfaces later as a client crashing on an absent key. The `scenarios/*.json` beside the binary are complete examples — one market record carries 38 required fields.

**Portfolio registers are per identity.** Seed without `X-Rystic-Actor` and it lands in `member`'s namespace — then trading as `maker` gets you `insufficient_balance` from a simulator you thought you had funded.

## 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. [Observing](https://www.rystic.ai/docs/concepts/observing.md) covers reading the log.

```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; 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; 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** — minutes of a dense replay tape. Check `dropped` before trusting it as complete.

## Determinism & reset

1. A static scenario or your own seed is byte-identical run to run: fills, position and balance deltas, and WS pushes follow your orders identically, and the clock steps once per request that reads it, never on wall time.
2. `btc15m_tape` plays at recorded pace on the wall clock, so what your bot meets depends on when it acts; fills, positions and pushes follow your orders exactly against the book they meet. `POST /_rystic/scenario` with `{"name":"btc15m_tape"}` puts it back and restarts its tape.

```bash
curl -X POST $B/_rystic/reset
curl -X POST $B/_rystic/seed -d @scenarios/01_no_arb.json    # from the install dir
# ... run your algo for N seconds ...
curl -s $B/trade-api/v2/portfolio/fills   # assert exact fills
```

[Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Settling a market

Kalshi settles on its own clock; the simulator settles when you tell it to. `POST /_settle` declares the result, cancels every resting order on the market, pays $1.00 per contract to the winning side, zeroes positions and writes the settlement history `GET /portfolio/settlements` reads. The body must be sent as JSON:

```bash
curl -s -X POST $B/_settle -H 'Content-Type: application/json' \
  -d '{"ticker":"KXBTC15M-26SEP030630-30","result":"yes"}'
# {"accounts_settled":0,"orders_canceled":…,"paid_dollars":"…","result":"yes","ticker":"KXBTC15M-26SEP030630-30"}
```

`accounts_settled` counts the accounts holding a position: `0` while the Quickstart's $0.10 bid rests unfilled, `1` once you hold contracts.

1. Without `Content-Type: application/json` it answers `400` `settle requires ticker and result (yes|no)`.
2. The market becomes `finalized` on an empty book (`yes_bid 0.0000`, `yes_ask 1.0000`) with the recorded `expiration_value` published: a later order create answers `404 market_not_found`; cancels still work. On `btc15m_tape` that is the scenario's only market: the tape's later frames leave the settled market alone, so it stays empty until `POST /_rystic/scenario` puts the scenario back.
3. Idempotent: the same result again is a `200` no-op with `"already_settled":true`; the opposite result is `409 settlement_conflict`; an unknown ticker is `404`.
4. `market_lifecycle_v2` subscribers receive `determined`, then `settled`.

## State UI

```
localhost:8080/_rystic/ui
```

Order book, per-bot P&L, order entry, captured-day replay, the Markets card that picks what [live mode](#live-mode) carries, and the activity bus.

## Limitations

1. **Cancels ahead of your quote are inferred, not observed.** Queue position advances on replayed trades and on cancels that provably came from ahead of the quote. A capture gives the size of each cancel but not its place in the queue, so cancels are attributed behind the quote first — the tightest position consistent with the tape, and never optimistic.
2. **No counterfactual response.** Recorded takers don't react to your quotes.
3. **A hand-seeded book only moves when you move it.** Replay, `/_rystic/seed`, or a scenario shipping its own tape moves one for you — the eleven regime scenarios, `arb_closes`, `closes_mid_run` and `btc15m_tape` do. `chop`, `pinned_at_fifty` and `btc15m_tape` loop forever; the others stop at the end of their tape. `degrades_mid_run`, `heals_after_outage` and `session_expires_mid_run` change the network over time, not the book.
4. **Lifecycle frames are the simulator's own.** `market_lifecycle_v2` sends `created` when the simulator mints a market and `determined` then `settled` on `/_settle`; a scheduled close sends nothing, as on live. The price feeds (`pyth_value`, `cfbenchmarks_value`) are silent on a tape.
5. **One process holds one exchange.** Run multiple ports for isolated books.
6. **The historical archive ships empty except in `has_history`.** Every `/historical/*` route answers, but only `has_history` seeds the registers behind them — elsewhere `historical/cutoff` reads epoch and the archived markets, orders, fills, trades and candlesticks come back 404 or empty. To backtest against your own archive, seed the `state_historical_*` registers and the cutoff through `/_rystic/seed` (`GET /_rystic/registers` lists their required fields).
7. **`btc15m_tape` carries the recorder's silences.** The session was captured with a recorder backlog: its exchange timestamps hold three gaps of about four minutes across a fourteen-minute window, and the tape replays on that axis, so the book freezes for minutes between bursts. The tape will be recaptured, not re-paced.
8. **The fidelity suite has no WebSocket tests.** The feed's control frames and the re-subscribe merge were checked by hand against demo; the differential suite that scores the REST surface does not yet reach the WebSocket.

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs kalshi; do not edit) -->

**Known issues.**

- `GET /exchange/schedule` does not answer the scenario's schedule: a scenario that seeds none reads a fixed placeholder (08:00–23:00 ET every day, no maintenance windows), and one that seeds a schedule reads it without its `schedule` envelope, where Kalshi answers its own published schedule inside that envelope.

**Required fields the twin may leave out.** The spec requires these in a success response, and the twin's response may not carry them:

- `GET /trade-api/v2/fcm/positions` (200): `market_positions/*/exchange_index`
- `GET /trade-api/v2/historical/fills` (200): `fills/*/exchange_index`
- `GET /trade-api/v2/portfolio/fills` (200): `fills/*/exchange_index`
- `GET /trade-api/v2/portfolio/positions` (200): `market_positions/*/exchange_index`
- `GET /trade-api/v2/portfolio/settlements` (200): `settlements/*/exchange_index`
- `GET /trade-api/v2/portfolio/summary/total_resting_order_value` (200): `resting_order_value_breakdown`

**Not measured.** The fidelity suite runs 364 tests, and 96 documented branches have none. 25 need a credential the measuring account does not hold (200 ×1, 401 ×16, 403 ×8). The other 71:

| Operation | Status | Why |
|---|---:|---|
| `DELETE /trade-api/v2/api_keys/{api_key}` | 400 | a well-formed request does not provoke it |
| `DELETE /trade-api/v2/communications/quotes/{quote_id}` | 204 | the operation is deprecated |
| `DELETE /trade-api/v2/communications/quotes/{quote_id}` | 404 | the operation is deprecated |
| `DELETE /trade-api/v2/communications/rfqs/{rfq_id}` | 400 | a well-formed request does not provoke it |
| `DELETE /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}` | 204 | nothing the twin serves creates the record it needs |
| `DELETE /trade-api/v2/portfolio/events/orders` | 429 | no request a client sends provokes it |
| `DELETE /trade-api/v2/portfolio/events/orders/batched` | 400 | a well-formed request does not provoke it |
| `GET /trade-api/v2/communications/quotes/{quote_id}` | 200 | the operation is deprecated |
| `GET /trade-api/v2/communications/quotes/{quote_id}` | 400 | the operation is deprecated |
| `GET /trade-api/v2/communications/quotes/{quote_id}` | 404 | the operation is deprecated |
| `GET /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}` | 200 | nothing the twin serves creates the record it needs |
| `GET /trade-api/v2/events` | 400 | no known request reaches it |
| `GET /trade-api/v2/events/multivariate` | 400 | no known request reaches it |
| `GET /trade-api/v2/fcm/orders` | 200 | no known request reaches it |
| `GET /trade-api/v2/fcm/orders` | 400 | a well-formed request does not provoke it |
| `GET /trade-api/v2/fcm/positions` | 200 | no known request reaches it |
| `GET /trade-api/v2/fcm/positions` | 400 | a well-formed request does not provoke it |
| `GET /trade-api/v2/historical/fills` | 400 | no known request reaches it |
| `GET /trade-api/v2/historical/markets/{ticker}/candlesticks` | 200 | no known request reaches it |
| `GET /trade-api/v2/historical/orders` | 400 | no known request reaches it |
| … and 45 more | | |

**Not modeled.** What the twin's operations leave out:

- Documented statuses not reproduced: 400 (4 operations), 401 (56 operations), 403 (3 operations), 404 (1 operation), 429 (1 operation), 500 (85 operations).
- No caller input reaches a 404, so the branch is not modeled and the twin mirrors the idempotent 204. (`DELETE /trade-api/v2/api_keys/{api_key}`)
- Cancelling a quote may, on the live exchange, emit out-of-band communications/RFQ egress (websocket quote-cancelled notifications to the RFQ creator) that this REST endpoint does not carry in its response. (`DELETE /trade-api/v2/communications/quotes/{quote_id}`)
- The endpoint is marked deprecated (superseded by DELETE /communications/rfqs/{rfq_id}/quotes/{quote_id}). The twin models it as a live behavioral alias with identical semantics; deprecation carries no distinct runtime branch and there is no declared 400/409, so a re-delete of an already-cancelled quote is treated as an idempotent 204 rather than an error. (`DELETE /trade-api/v2/communications/quotes/{quote_id}`)
- A syntactically valid rfq_id that matches no RFQ still routes to the existence 404. (`DELETE /trade-api/v2/communications/rfqs/{rfq_id}`)
- The subaccount, exchange_index, and market_ticker query params are optional routing knobs (market_ticker is documented as required only when exchange_index=-1 auto). (`DELETE /trade-api/v2/portfolio/events/orders/{order_id}`)
- Canceling the group's member orders may, on the live exchange, emit out-of-band order-lifecycle egress (websocket/fill-feed cancel notifications) that this REST endpoint does not carry in its response. (`DELETE /trade-api/v2/portfolio/order_groups/{order_group_id}`)
- The subaccount and exchange_index query params are optional routing knobs. (`DELETE /trade-api/v2/portfolio/order_groups/{order_group_id}`)
- The operation declares 401 (Unauthorized) and 500 (Internal server error). (`GET /trade-api/v2/account/limits`)
- The ApiKey read schema omits the stored public_key (RSA PEM) and internal created timestamp; these are never surfaced by GetApiKeys, so no reproducible-value or timestamp policy is needed on the 200 body. (`GET /trade-api/v2/api_keys`)
- No 400 Bad request is declared for this single-item read, and the only caller input is the required 'rfq_id' path parameter (an unconstrained string), so there is no caller-driven malformed-id branch to model. (`GET /trade-api/v2/communications/rfqs/{rfq_id}`)
- Multiple overlapping/successive WeeklySchedule entries in standard_hours over different effective ranges. (`GET /trade-api/v2/exchange/schedule`)
- Multiple sessions per weekday (each weekday array may hold more than one DailySchedule) — example shows a single session per day. (`GET /trade-api/v2/exchange/schedule`)
- The operation declares 500/503/504 responses (internal error, service unavailable, gateway timeout), each returning an ExchangeStatus body. (`GET /trade-api/v2/exchange/status`)
- Live membership growth of market_positions/event_positions (new positions arising from live fills/settlements over time) is out of scope: the twin seeds a frozen synthetic snapshot and compares the collections by shape only. (`GET /trade-api/v2/fcm/positions`)
- The server-minted fill_id/trade_id/order_id remain 'shape' and the pagination cursor remains 'ignore' (an opaque continuation mechanism, not an archive fact). (`GET /trade-api/v2/historical/fills`)
- The operation declares a 404 NotFoundError, but this is a plain collection list endpoint with no path-scoped resource id -- ticker/max_ts only narrow the filtered result set (an unmatched filter yields an empty list, not a 404). (`GET /trade-api/v2/historical/fills`)
- The operation's description states 'Filters are mutually exclusive' (only one of tickers/event_ticker/series_ticker at a time, per the sibling live /markets pattern). (`GET /trade-api/v2/historical/markets`)
- No 400 Bad request is declared for this single-item read, and the only caller input is the required 'ticker' path parameter (a plain string with no UUID/opaque format), so there is no caller-driven format-violation branch to model. (`GET /trade-api/v2/historical/markets/{ticker}`)
- No documented behavior for start_ts > end_ts (an inverted window); the operation description only specifies inclusive boundary semantics per parameter, not their relative ordering, so no branch is modeled for that case. (`GET /trade-api/v2/historical/markets/{ticker}/candlesticks`)
- The operation declares a 404 Resource not found response, but GET /historical/trades is a query-filtered list endpoint with no path parameter selecting a single resource; an optional ticker filter that matches no archived trades yields an empty 'trades' array (200), not a 404, so this is not modeled as a caller-triggered branch. (`GET /trade-api/v2/historical/trades`)
- … and 72 more

<!-- END generated: known-gaps -->

## WebSocket feed

Kalshi's WS surface at `/trade-api/ws/v2`. Point `WS_BASE_URL` at the simulator; your streaming client connects unchanged.

| 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 `/_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` | *(nothing yet)* | subscription accepted |
| `order_group_updates` | *(nothing yet)* | subscription accepted |

1. Protocol matches live: `subscribe` / `unsubscribe` / `update_subscription`, a `subscribed` confirmation carrying each channel's `sid`, `{type, sid[, seq], msg}`, 10 s server `heartbeat`.
2. A tape frame arrives as incremental `orderbook_delta` frames plus a `ticker` update when the quote moved.
3. Every frame is also recorded in the [egress log](#egress-log).

### Drop a connection on purpose

A reconnect bug in a one-to-many exchange client is fleet-wide. The simulator cuts sockets on demand so the test can prove the bot comes back.

```bash
curl -s $B/_rystic/ws                      # 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-…"]}]}]}
curl -X POST $B/_rystic/ws-drop -d '{"tickers":["KXBTC15M-26SEP030630-30"]}'
# {"dropped":[1]}
curl -X POST $B/_rystic/ws-drop -d '{"connections":[2,3]}'
curl -X POST $B/_rystic/ws-drop -d '{"all":true,"graceful":true}'
```

1. Select by `tickers` (any socket whose subscription names one), by `connections` (ids from `GET /_rystic/ws`), or `all`. A selector that matches nothing answers `{"dropped":[]}`.
2. Default is a bare close with no close frame — 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` afterwards lists what is still open.
4. The drop is recorded on the event stream (`rystic tail`) as a control op.

## Exchange shards

Kalshi runs its exchange as shards numbered `0`–`3`. Every market lives on one, named by its `exchange_index`, and an account holds cash per shard (`balance_breakdown`) and trades a market only on a shard where it is provisioned (`exchange_presence`). The simulator does the same: an order routes to its market's shard, by the body's `exchange_index` or, omitted or `-1`, by ticker, and its fill debits that shard's cash.

1. A balance seeded without `balance_breakdown` is a one-shard account on shard 0. An order on a market on another shard answers `404 user_not_found`, as on the exchange.
2. A replayed or live-fed market keeps its real shard, read from its market record; with no record ([The one outbound call](#the-one-outbound-call) turned off or unreachable, and no `meta/`) it sits on shard 0. `GET /trade-api/v2/markets/{ticker}` shows it, and `GET /trade-api/v2/portfolio/balance?exchange_index=2` shows that shard's cash. Fund the shard in the seed:

```json
{"state_balance": {"member": {
  "balance": 1000000, "balance_dollars": "10000.0000",
  "portfolio_value": 0, "updated_ts": 1784989800,
  "balance_breakdown": [{"exchange_index": 0, "balance": "0.0000"},
                        {"exchange_index": 2, "balance": "10000.0000"}],
  "exchange_presence": [0, 2]
}}}
```

3. **One cash pool:** start the simulator with `KALSHI_REPLAY_SHARD=0` and every market a tape or the live feed creates is placed on shard 0, whatever its record says, so a plain balance seed funds them all. Any other non-negative index works the same way; a value that isn't one is ignored and logged. This gives up shard fidelity: routing, per-shard cash and transfers no longer reflect where the markets really live. A market you seed yourself keeps the `exchange_index` you give it.

## Multiple bots

Every identity gets its own portfolio. Precedence, on REST and the WS upgrade alike:

1. `X-Rystic-Actor` — the explicit override.
2. `KALSHI-ACCESS-KEY` — an unmodified signing bot gets its own account for free. The key id is a label, never verified; the simulator holds no key material.
3. `member` — headerless callers (curl, the state UI) share the default account.

```bash
curl -s localhost:8080/trade-api/v2/portfolio/positions -H 'X-Rystic-Actor: maker'
```

Two bots on one simulator can trade against each other, private WS frames included. Another actor's `order_id` 404s rather than leaking.

One gap: the RFQ desk. `/communications/rfqs`, `/communications/rfqs/{id}` and `/communications/quotes/{id}` are one shared pool — two desks negotiating on one simulator will see each other.

## Replay a captured market day

Point `KALSHI_CAPTURE_DIR` at a capture archive (default `~/data/kalshi-orderbook`) and the simulator plays a recorded session *through* the matching engine. From the CLI, `rystic run --tapes <dir>` (or `RYSTIC_TAPES`) sets it and validates the library against the simulator's manifest before start; `--tape <id>` starts replaying that market once the simulator is serving.

```bash
rystic run kalshi-twin --name replay --tapes ~/tapes --tape <id> --speed 5     # replay a captured day
rystic run kalshi-twin --name replay --tapes ~/tapes --tape <id> --copies 20   # one tape as 20 markets, 20 WS streams
```

```bash
curl -s localhost:8080/_replay/markets
curl -X POST localhost:8080/_replay/start \
  -H 'Content-Type: application/json' \
  -d '{"market":"KXBTC15M-26AUG010000-00","speed":5,"rebase":true}'
curl -X POST localhost:8080/_replay/pause
curl -X POST localhost:8080/_replay/play -d '{"speed":20}'
curl -X POST localhost:8080/_replay/seek -d '{"position":80000}'
curl -s localhost:8080/_replay/status
```

1. Every recorded delta, trade and ticker renders as a real WS frame on the sequence your orders use; the clock tracks tape time. Recorded gaps hold, divided by `speed`.
2. **Replayed flow fills your resting orders** — at maker fee 0, moving position and balance, pushing `fill` / `user_orders` / `market_positions`.
3. **`rebase` shifts the tape onto wall time.** Leave it on; a bot computing market hours from the wall clock reads an un-rebased tape as expired.
4. **`seek` is exact in both directions** — frames are complete snapshots. Don't scrub mid-backtest. `position` is an event index into the tape (the first event is `0`), not a time.
5. `status` reports `mode`; `position`, the next event's index; `position_ms` and `duration_ms`, tape time played and in total; `rebase_offset_ms`, how far `rebase` shifted the tape's timestamps onto wall time (wall time at start minus the tape's first event, fixed for the run); and `inject`, the loaded schedule's counts, absent with none.
6. **`inject` on `start`** names an inject file on the simulator's filesystem, `{"events":[…]}` or one event per line. Its `at` values are tape time: each event fires when replay reaches that instant, and on a rebased run a seed's timestamps shift by `rebase_offset_ms` as it fires. A bad file answers `400` and nothing starts; play-again re-shifts the schedule; a backward `seek` does not fire an event again. `rystic run --tape <id> --inject <file>` sends it this way.
7. **`POST /_replay/prepare`** takes the same `market`, `markets` and `copies` as `start`, parses the tapes and answers `204` without playing them. A large tape's parse is the slow part, so prepare at boot and a `start` naming the same markets, in the same order with the same `copies`, skips it; the parse is used once.
8. **`GET /_replay/stream?market=<id>`** returns a tape, named as `_replay/markets` lists it (not a copy's ticker), as NDJSON, one line per event (`position`, `time_ms`, `kind` and the event's fields), without touching what is playing.

### Tape format

Write tapes from your own process and the simulator replays them as-is. Only `events/` is required:

```
<tapes>/
  events/<MARKET_TICKER>/<YYYY-MM-DD>.ndjson.gz   # required: one file per market per UTC day
  meta/<any-name>.ndjson.gz                       # optional: market records
```

**`events/`**: gzipped NDJSON. Each line wraps one Kalshi WebSocket message exactly as received:

```json
{"recv_ms":1787234444328,"event":{"type":"orderbook_delta","sid":1,"seq":3261604,"msg":{"market_ticker":"KXBTCD-26AUG2011-T71599.99","market_id":"2fb58232-c672-4527-bf81-cf892b4e8762","price_dollars":"0.5000","delta_fp":"-274.00","side":"yes","ts":"2026-08-20T14:00:44.292945Z","ts_ms":1787234444292}}}
```

1. `recv_ms` is when your process read the frame, Unix ms. `event` is the message, unmodified.
2. `orderbook_snapshot`, `orderbook_delta` and `trade` are replayed; the simulator renders ticker frames from them. Any other type, a line that isn't JSON and a delta whose `side` is neither `yes` nor `no` are skipped and counted in the simulator log, so record only those three.
3. **Start each market with an `orderbook_snapshot`.** Kalshi sends one on subscribe and on every resubscribe; log from the subscribe. A delta before any snapshot lands on an empty book.
4. The clock is the exchange's `msg.ts_ms`. A snapshot has none and takes the next stamped line's; `recv_ms` is used only when no line in the tape has a stamp. A stamp that steps backwards is held at the one before it.
5. Day files play in filename order, so a session that crosses midnight UTC is one tape. Any name ending `.ndjson.gz` works if the names sort in time order.
6. Appending gzip members to an open file is fine. A file that stops mid-stream ends the tape at its last whole line, skips any later files, and the simulator logs it. If nothing was read before the break, the replay fails instead.

**`meta/`**: optional. One line per market record:

```json
{"recv_ms":1787234444000,"kind":"market","key":"KXBTCD-26AUG2011-T71599.99","data":{"ticker":"KXBTCD-26AUG2011-T71599.99","floor_strike":71599.99,"strike_type":"greater",…}}
```

1. `data` is the `market` object from `GET /trade-api/v2/markets/{ticker}`, verbatim. Strike, title and price grid are served from it.
2. Write it when you start recording a market. With several records for one market, the earliest `recv_ms` wins.
3. With no record, the simulator fetches one on first play and, when the library is writable, caches it into `meta/` ([the one outbound call](#the-one-outbound-call)); with a read-only library it fetches again on every run. Offline, the replay still runs, without strike fields.

Nothing else is needed: no index or CSV. Leave out a `manifest.json` at the library root, which the simulator reads as a capture-window manifest and which must then list every market you replay. Check a tape:

```bash
rystic run kalshi-twin -d --name tapecheck --tapes <tapes> --tape <MARKET_TICKER>
curl -s localhost:8080/trade-api/v2/markets/<MARKET_TICKER> | jq '.market | {floor_strike, strike_type}'
rystic stop tapecheck
```

### One tape as many markets

Sockets are split by ticker, so replaying one tape N times is N runs on one ticker and one socket. `copies` plays the same tape as N distinct markets on one clock, each under its own ticker, each on its own stream.

```bash
curl -X POST localhost:8080/_replay/start   -d '{"market":"KXBTC15M-26AUG010000-00","copies":20,"speed":1,"rebase":true}'
curl -s localhost:8080/_replay/status | jq '.markets[].market'
# "KXBTC15M-26AUG010000-00-1" … "KXBTC15M-26AUG010000-00-20"
curl -X POST localhost:8080/_replay/start   -d '{"markets":[{"tape":"KXBTC15M-26AUG010000-00","as":"KXLOAD-A"},{"tape":"KXBTC15M-26AUG010000-00","as":"KXLOAD-B"}],"speed":1}'
```

1. `copies: N` names them `<tape>-1` … `<tape>-N`; `{"tape", "as"}` entries in `markets` let you pick the names yourself. Mix them with plain tapes freely.
2. REST and WS agree on the new ticker: `markets`, `orderbook`, `fill`s, `positions` and every frame carry it. `status` shows which `tape` each member plays.
3. The real record (strike, title, grid) carries over from the tape's own ticker; the alias mints its own `ticker` and `event_ticker`.
4. Each copy is folded separately, so memory scales with copies × tape size. Prefer short captures for wide fan-outs.
5. `rystic run kalshi-twin --tape <id> --copies 20` does the same from the CLI.

### Replay in Compose

Add a read-only tape mount and override `command`; the CLI prepares and starts playback after the HTTP server is ready:

```yaml
environment:
  RYSTIC_TOKEN: ${RYSTIC_TOKEN}
  RYSTIC_TAPES: /tapes
volumes:
  - rystic-leases:/var/lib/rystic/leases
  - ./tapes:/tapes:ro
command: ["rystic", "run", "--live=false", "--tape", "<recorded-ticker>", "--speed", "1", "--copies", "3"]
```

Enterprise swaps `RYSTIC_TOKEN` for `RYSTIC_LICENSE: /run/secrets/rystic-license.json` with the license mounted read-only (`./rystic-license.json:/run/secrets/rystic-license.json:ro`) in place of the lease volume ([API key and Enterprise license](#api-key-and-enterprise-license)). Declare `rystic-leases` under the file's top-level `volumes:`, as in [Docker → Compose](https://www.rystic.ai/docs/docker.md#compose).

Omit `--copies` for the tape's original ticker. With three copies the markets are `<recorded-ticker>-1`, `-2`, and `-3`. Select live feed or tape replay for a run, not both. There is no environment variable for `--tape`, `--speed`, `--copies` or `--seed-rng` (which pins the simulator's RNG once it serves, as `POST /_rystic/seed-rng` does); they go in `command`. Read [the outbound metadata call](#the-one-outbound-call) below before mounting an archive read-only.

### The one outbound call

A capture holds a market's book and its prints, not the market itself — and on a threshold market the strike *is* what the market means (`KXBTCD-26AUG2017-T73749.99` is `floor_strike 73749.99`, `strike_type "greater"`). So replaying a tape whose archive has no `meta/` log makes the simulator fetch that market's public record: one unauthenticated `GET`, first play only, cached back into your archive. Apart from the Free and Pro check-in with `lease.rystic.ai` ([what it sends](https://www.rystic.ai/docs/licensing.md#what-a-running-simulator-sends)), it is the only outbound call the simulator makes; an Enterprise simulator's license check stays local.

```bash
RYSTIC_REPLAY_METADATA=off ./kalshi-twin              # never fetch
RYSTIC_REPLAY_METADATA_HOSTS=api.elections.kalshi.com ./kalshi-twin   # fetch, but only there
```

1. **Off** costs the strike fields and nothing else — the grid is synthesized from the series, and the simulator logs which market it could not describe.
2. **Pinned** bounds the blast radius: any other destination is refused before the connection is made.
3. Even unset, the simulator speaks only `https`, never follows a redirect off its starting host, and logs the host it dialed.
4. **What it costs to leave on:** replaying a tape tells the exchange which historical window you are studying.
5. The market is still replayed as it *was* — lifecycle from the fetched record is never copied.
6. A failed fetch never fails a replay. **Blocked egress degrades your markets quietly**, and nothing else goes red.

Parity without the call, best first: ship `meta/` with your tapes (a current capture writes it; [format](#tape-format)), seed the market's record before starting replay, or take the loss with `off`.

## Backtesting

`rystic batch` replays every tape in a library through its own simulator, your bot against each, and ends in a per-market P&L table. It has its own page: [Kalshi backtesting](https://www.rystic.ai/docs/kalshi-backtesting.md).

## Live mode

The simulator can be **fed** instead of played, through `/_replay/feed`. The CLI holds the exchange socket and pushes every frame in, so your bot trades against a book tracking the real market right now. Nothing you send the simulator reaches Kalshi.

```bash
rystic run kalshi-twin --live --name live           # boots it, opens the live session, feeds it from this process
rystic run kalshi-twin --live -d --name live         # detached: `rystic ps` shows "live · 1 carried · lag 120 ms"
rystic feed kalshi-twin --url http://localhost:8080  # a simulator already running — any runner, any machine
curl -s localhost:8080/_replay/status                # {"mode":"live","playing":true,"frames":18213,"last_ms":…,"lag_ms":412,…}
```

1. **No key setup.** The CLI carries a read-only exchange key — `rystic doctor kalshi-twin` shows `live feed … built into this release`. `KALSHI_KEY_ID` (or `KALSHI_KEY_ID_FILE`, a path to a file holding it) plus `KALSHI_PRIVATE_KEY_FILE` (a path) or `KALSHI_PRIVATE_KEY_B64` (the PEM, base64) use your own key instead; one form of each. With none of them set, the CLI reads `/run/secrets/kalshi_key_id` and `/run/secrets/kalshi_private_key` — see [In Compose](#in-compose).
2. **Nothing is carried until you pick.** The feeder offers the 500 busiest open markets to the Markets card in the [state UI](#state-ui); add one and its frames start. `--live-carry N` pre-carries the busiest N; `--live-markets A,B` carries exactly those; `--live-events E1,E2` carries every open market of those events; `--live-series KXBTC15M,KXETH15M` narrows the offer to those series. Naming markets or events turns `--live-carry` off: the names are the whole selection, and the feeder log says the carry is ignored. Every `--live-*` flag also reads `RYSTIC_LIVE_*` from the environment, so a container starts fed with no argv. The exchange is rescanned every five minutes, so a market that just opened shows up within that.
3. **`lag_ms` is the number to watch** — wall now minus the exchange stamp of the last applied frame. It includes the exchange's own delivery delay, which reaches tens of seconds at peak load on every socket at once, so more `--live-shards`, fewer markets or a reconnect will not lower it. The feeder's `[status]` line in `rystic logs --feed <name>` splits it: `upstream_lag_ms` is receive time minus the exchange stamp, so a `lag_ms` that tracks it is the exchange running late, and one well above it is the feeder or the simulator falling behind; `post_ms` is each batch's round trip to the simulator, apply included.
4. Every frame folds through the same path a tape frame does, so fills, positions and the WebSocket feed behave exactly as in [replay](#replay-a-captured-market-day) — live flow fills your resting orders.
5. **Paper-trading ceiling.** Your orders fill against the real book and prints but never move them, and queue position is what replay already claims.
6. What changes from replay: there is no tape, so no `total`, `position` or `duration_ms` — `status` carries `frames`, `dropped`, `last_ms`, `lag_ms`. `seek` answers 409, `pause` drops frames instead of queuing them, `speed` is 1.
7. A carried market the simulator has not seen is minted on its first frame and its record fetched then (see [The one outbound call](#the-one-outbound-call)), so `close_time` and the expiration fields are the exchange's own.
8. Delivery is best-effort. A slow simulator costs frames (`dropped` in the `[status]` line, with the queue depth as `queued` and `peak`), and a dropped delta leaves that market's book behind until its next snapshot. A frame the exchange never delivered shows as a sequence gap: the feeder resubscribes that socket for fresh snapshots and counts it in `gaps` and `lost`. `reconnects` counts sockets the exchange dropped, and `errors=25:3` counts the exchange's error frames by code.
9. The feed is `POST /_replay/feed`, NDJSON, one capture line per row; answers `{"accepted":n,"dropped":n,"drops":{reason:n}}`. A market's first frame must be an `orderbook_snapshot` — only matters if you hand-roll a feeder.
10. The carried list is `/_replay/watch`, the same list the Markets card edits, so a script can change it at runtime. `GET` answers `{"available":[…],"watch":[…],"seen":[…]}`: the feeder's offer, the picks, and the markets that frames have arrived for; an empty list is `null`. `POST {"markets":["A","B"]}` replaces the picks (`204`), and the feeder resubscribes within about two seconds; `{"markets":[]}` clears them. Only markets in `available` are carried, and a pick outside it is ignored without an error. Markets and events named at startup are added back to the picks on every rescan, so a `POST` can add to them but not drop them for good. With nothing named, `--live-carry N` refills an empty list with the busiest N. A body naming neither `markets` nor `available` answers `400`, and a `POST` outside a live session `409`. Events have no HTTP form: name them at startup with `--live-events` or `RYSTIC_LIVE_EVENTS`.

```bash
curl -s localhost:8080/_replay/watch | jq '{watch, seen}'
curl -X POST localhost:8080/_replay/watch -d '{"markets":["KXBTC15M-26AUG010000-00"]}'
```

### Flags and environment

```bash
rystic run kalshi-twin --live --name live            # boot + feed from this process; Ctrl-C stops both
rystic run kalshi-twin --live -d --name live          # detached: `ps` shows "live · 1 carried · lag 120 ms"
rystic run kalshi-twin --live --runner docker -d --name live   # the container plus a feeder on this host
rystic feed kalshi-twin --url http://localhost:8080   # a simulator already running — any runner, any machine
rystic feed kalshi-twin --instance live               # …or a detached instance by name
rystic logs --feed live                               # the feeder's log
```

| Flag | Env | Default | Meaning |
|---|---|---|---|
| `--live` | `RYSTIC_LIVE=1` | off | feed the simulator from the exchange |
| `--live-carry N` | `RYSTIC_LIVE_CARRY` | `0` | pre-carry the busiest N markets; `0` carries nothing until the state UI picks; ignored when markets or events are named |
| `--live-markets a,b` | `RYSTIC_LIVE_MARKETS` | — | carry exactly these markets before anything is picked; the state UI can change the list later |
| `--live-markets-file path` | `RYSTIC_LIVE_MARKETS_FILE` | — | read the startup watch list from a shared file; alternative to `--live-markets` |
| `--live-events a,b` | `RYSTIC_LIVE_EVENTS` | — | carry every open market of these events, looked up again on every rescan; adds to `--live-markets` |
| `--live-events-file path` | `RYSTIC_LIVE_EVENTS_FILE` | — | read the startup event list from a shared file; alternative to `--live-events` |
| `--live-series a,b` | `RYSTIC_LIVE_SERIES` | `*` | series to carry; `*` offers every open market on the exchange |
| `--live-max-markets N` | `RYSTIC_LIVE_MAX_MARKETS` | `500` | how many of the busiest open markets the state UI offers |
| `--live-shards N` | `RYSTIC_LIVE_SHARDS` | `3` | exchange sockets the carried markets spread over, max 5 |
| `--live-env` | `RYSTIC_LIVE_ENV` | the simulator's default (`prod`) | exchange environment; `demo` needs your own demo key |

Flag > env > default. Every `--live-x-y` reads `RYSTIC_LIVE_X_Y`, so a container or orchestrator sets the whole feed with no argv. A `--live-*` flag or `live_*` stack key without live mode is refused, and so is a `RYSTIC_LIVE_*` variable unless the twin declares no live feed; `--live=false` (`RYSTIC_LIVE=0`, `live: false`) turns live off on purpose and ignores them. A `RYSTIC_LIVE_*` name that is no live setting is refused with the setting it is closest to (`RYSTIC_LIVE_EVENT_FILE` → did you mean `RYSTIC_LIVE_EVENTS_FILE`?), and `run`, `up` and `feed` warn about any variable a typo away from a Rystic one (`RYSTICE_LIVE_EVENTS_FILE`).

1. The exchange socket lives in the CLI process — under `--runner docker` too, where the feeder runs on the host beside the container. The simulator itself dials no exchange, and nothing your bot sends it reaches the exchange.
2. **Your own key.** A release build carries a read-only exchange key; `doctor` reports it as `built into this release`. `KALSHI_KEY_ID` (or `KALSHI_KEY_ID_FILE`, a path to a file holding it) plus `KALSHI_PRIVATE_KEY_FILE` (a path) or `KALSHI_PRIVATE_KEY_B64` (the PEM, base64) use your own key instead; one form of each, never both. With none of them set, the CLI reads `/run/secrets/kalshi_key_id` and `/run/secrets/kalshi_private_key`, both or neither. The environment wins over `/run/secrets`, and `/run/secrets` over the built-in key. Neither is your Rystic API key or license.
3. Only a simulator whose manifest declares a feed takes `--live` — today `kalshi-twin`. `rystic features` lists it as **live feed**.
4. `stop` ends the feeder first, then the simulator; `restart` re-runs the recorded spec, feed included.

### In a stack file

```yaml
services:
  c: {product: kalshi-twin, port: 0, live: true, live_markets: "KXBTC15M-26SEP1712-T1, KXBTC15M-26SEP1712-T2"}
```

`live: true` takes every `--live-*` default (env included); `live_env`, `live_series`, `live_max_markets`, `live_shards`, `live_carry`, `live_markets`, `live_markets_file`, `live_events`, `live_events_file` each set one. An explicit empty string clears an inherited list. `tapes`, `tape` and `copies` are the replay keys. Compose export preserves explicit live settings and mounts `live_markets_file` and `live_events_file`.

### In Compose

Keep the default `rystic run` command and the credential the export wrote, and add variables under the service's `environment`. Quote numeric and boolean values:

```yaml
environment:
  RYSTIC_TOKEN: ${RYSTIC_TOKEN}
  RYSTIC_LIVE: "1"
  RYSTIC_LIVE_SERIES: KXBTC15M
  RYSTIC_LIVE_SHARDS: "2"
  RYSTIC_LIVE_CARRY: "1"
```

The CLI and its feeder run **inside this container**. No host feeder or Docker socket mount is needed. `command: ["rystic", "run", "--live=false"]` overrides `RYSTIC_LIVE: "1"` for a replay or scenario run. The health check confirms the HTTP server is available; for live data, wait for the selected markets and their books as well — discovery and exchange subscription happen after HTTP startup. `RYSTIC_LIVE_SHARDS` is capped at 5: Kalshi permits at most 5 sockets per key across all clients. To use your own key, put `key_id` (the key id, one line) and `kalshi.pem` next to `compose.yaml` and add Compose secrets — no key variables needed. A `token` file holding your `rys_…` API key, as the `rystic_token` secret, replaces `RYSTIC_TOKEN` the same way, so a shared `compose.yaml` holds no per-person value:

```yaml
services:
  kalshi-twin:
    secrets: [rystic_token, kalshi_key_id, kalshi_private_key]
secrets:
  rystic_token: {file: ./token}
  kalshi_key_id: {file: ./key_id}
  kalshi_private_key: {file: ./kalshi.pem}
```

`rystic doctor kalshi-twin` then reports `live feed … (from /run/secrets)`. The container runs as uid 10001, and Compose mounts a file secret with the host file's owner and mode (it ignores `uid` and `mode` on one), so on a Linux host grant that uid read first — `setfacl -m u:10001:r token key_id kalshi.pem` (the `acl` package), or `sudo chown 10001 token key_id kalshi.pem` — rather than making the key readable by every user; a 0600 file fails with `permission denied` and the uid that needs it. `KALSHI_KEY_ID` holds the id itself; a path there is refused and pointed at `KALSHI_KEY_ID_FILE`. `RYSTIC_TOKEN_FILE`, `KALSHI_KEY_ID_FILE` and `KALSHI_PRIVATE_KEY_FILE` point at files anywhere else in the container; `KALSHI_PRIVATE_KEY_B64` carries the PEM inline.

### Share a ticker file with your bot

Create `tickers.txt` next to `compose.yaml`. Put the exact open market tickers your bot will use in it, one per line or comma-separated. Blank lines, duplicates and `#` comments are accepted. Add these settings to the simulator service:

```yaml
environment:
  RYSTIC_TOKEN: ${RYSTIC_TOKEN}
  RYSTIC_LIVE: "1"
  RYSTIC_LIVE_MARKETS_FILE: /config/tickers.txt
volumes:
  - rystic-leases:/var/lib/rystic/leases
  - ./tickers.txt:/config/tickers.txt:ro
```

Mount the same host file into your bot service:

```yaml
volumes:
  - ./tickers.txt:/config/tickers.txt:ro
```

Configure your bot to read `/config/tickers.txt` using its own file option. Rystic does not prescribe the bot's flag or environment-variable name. Both containers now read the same list without a shell wrapper. The equivalent explicit Rystic command is:

```yaml
command: ["rystic", "run", "--live", "--live-markets-file", "/config/tickers.txt"]
```

Use either `RYSTIC_LIVE_MARKETS` or the file, not both. Rystic reads the file once at startup; it is not a file watcher. The state UI can change the watch list afterward. After editing the file, recreate the services so both processes and bind mounts read the new contents:

```bash
docker compose up -d --force-recreate
```

A CLI `rystic restart` reuses the resolved startup list recorded for that instance. Explicit tickers survive activity and offer-size filters, but must still be open and within `RYSTIC_LIVE_SERIES`; unavailable tickers are named in the feeder log.

### Share an events file with your bot

If your bot trades by event, share event tickers instead of market tickers. Put them in `events.txt`, in the same format as `tickers.txt`, and set `RYSTIC_LIVE_EVENTS_FILE` (or pass `--live-events-file`):

```yaml
environment:
  RYSTIC_LIVE: "1"
  RYSTIC_LIVE_EVENTS_FILE: /config/events.txt
volumes:
  - ./events.txt:/config/events.txt:ro
```

1. The feeder carries every open market of each named event. It looks each event up again on every rescan, so a strike the exchange adds later is carried from the next scan, and a market that closes is dropped.
2. Name event tickers (`KXBTCD-26OCT0617`), not series (`KXBTCD`) or market tickers. The feeder log names a series or an open market listed as an event, with the flag that carries it.
3. An event with no open markets carries nothing and is named once in the feeder log. Name an upcoming event ahead of time and its markets are carried once they open.
4. Events add to `RYSTIC_LIVE_MARKETS` or its file; set both to carry named markets as well. `RYSTIC_LIVE_EVENTS` and the events file are alternatives, like their market equivalents.
5. `--live-series` does not narrow named events: each one is looked up directly.
6. Like the markets file, the events file is read once at startup. A CLI `rystic restart` reuses the resolved event list.
7. A market enters the simulator on its first frame. A bot that lists an event's markets from the simulator right after startup can see only part of the event for a few seconds; wait until every market has its book.

## Cookbook

### Trade the recorded tape

`btc15m_tape`'s market moves on its own, and `member` holds $10,000:

```bash
T=KXBTC15M-26SEP030630-30
curl -s $B/trade-api/v2/markets/$T/orderbook | jq -c '{yes: .orderbook_fp.yes_dollars[-1], no: .orderbook_fp.no_dollars[-1]}'
curl -s -X POST $B/trade-api/v2/portfolio/events/orders -H 'Content-Type: application/json' \
  -d "{\"ticker\":\"$T\",\"client_order_id\":\"q1\",\"side\":\"bid\",\"count\":\"1\",\"price\":\"0.10\",
       \"time_in_force\":\"good_till_canceled\",\"self_trade_prevention_type\":\"taker_at_cross\"}"
# -> 201 {"order_id":…}
curl -s $B/trade-api/v2/portfolio/orders | jq -r '.orders[] | "\(.order_id) \(.status)"'   # newest first
curl -s -X POST $B/_rystic/scenario -d '{"name":"btc15m_tape"}'   # the world back, orders gone
```

### Seed a book and trade it

`maker` rests a quote, `taker` crosses it. `empty_book` seeds `KXDEMO-26-A` — a complete market record with no quotes on it:

```bash
curl -X POST $B/_rystic/scenario -d '{"name":"empty_book"}'
```

Balances are per identity, so fund both. All four fields are required — `balance` is cents, `balance_dollars` the same money as a decimal string:

```bash
curl -X POST $B/_rystic/seed -d '{
  "state_balance": {
    "maker": { "balance": 1000000, "balance_dollars": "10000.0000",
               "portfolio_value": 0, "updated_ts": 1784989800 },
    "taker": { "balance": 1000000, "balance_dollars": "10000.0000",
               "portfolio_value": 0, "updated_ts": 1784989800 } } }'
```

```bash
curl -s -X POST $B/trade-api/v2/portfolio/events/orders \
  -H 'Content-Type: application/json' -H 'X-Rystic-Actor: maker' \
  -d '{"ticker":"KXDEMO-26-A","client_order_id":"m1","side":"ask","count":"10","price":"0.40",
       "time_in_force":"good_till_canceled","self_trade_prevention_type":"taker_at_cross"}'
# -> 201 {"order_id":…,"fill_count":"0.00",…}: resting, user_id "maker"

curl -s $B/trade-api/v2/markets/KXDEMO-26-A/orderbook
# {"orderbook_fp":{"no_dollars":[["0.6000","10.00"]],"yes_dollars":[]}}

curl -s -X POST $B/trade-api/v2/portfolio/events/orders \
  -H 'Content-Type: application/json' -H 'X-Rystic-Actor: taker' \
  -d '{"ticker":"KXDEMO-26-A","client_order_id":"t1","side":"bid","count":"10","price":"0.40",
       "time_in_force":"fill_or_kill","self_trade_prevention_type":"taker_at_cross"}'
# -> 201 {"order_id":…,"fill_count":"10.00","average_fill_price":"0.4000","average_fee_paid":…}
```

The taker's fee is the fee schedule's: `ceil(0.07 × fee_multiplier × count × price × (1 − price))` per fill, at the micro-dollar, with `fee_multiplier` the series' own (`1` on every production series, `KXBTC15M` included).

Read it back with the same header you traded under:

```bash
curl -s $B/trade-api/v2/portfolio/fills -H 'X-Rystic-Actor: maker'
# -> one fill, "is_taker": false
curl -s $B/trade-api/v2/portfolio/positions -H 'X-Rystic-Actor: maker'
# -> "position_fp": "-10.00" — short 10
```

Fees are per series: `KXDEMO` declares none. A series that declares a fee charges the taker, micro-ceiled per fill.

### Backtest a bot on a recorded day

```bash
curl -s $B/_replay/markets      # pick a captured ticker
curl -X POST $B/_replay/start \
  -H 'Content-Type: application/json' \
  -d '{"market":"KXBTC15M-26AUG010000-00","speed":1,"rebase":true}'
# ... run your bot ...
curl -s $B/trade-api/v2/portfolio/fills   # what it got done
curl -s "$B/_rystic/egress?tail=100"      # the frames it saw
```

`speed: 1` is the measurement setting — a bot on the wall clock sees the market it would have seen live.

### Byte-identical CI runs

From the install dir, where `scenarios/` sits:

```bash
curl -X POST $B/_rystic/reset
curl -X POST $B/_rystic/seed -d @scenarios/01_no_arb.json
curl -X POST $B/_rystic/clock -d '{"time":"2026-07-23T00:00:00Z"}'
curl -X POST $B/_rystic/seed-rng -d '{"seed":42}'
# ... run your algo ...
curl -s $B/trade-api/v2/portfolio/fills > run.json
# diff run.json against the last green run — bytes, not vibes
```

### Make the simulator as slow as production

```bash
curl -X POST $B/_rystic/network -d '{"enabled":true}'
curl -s $B/_rystic/network      # the active per-endpoint profile
```

Latency and jitter measured against production Kalshi from us-east-2, the region it is served from — reads 7–23 ms p50, order create/cancel 12–13 ms p50 against a quiet book. A strategy that only works against a zero-latency book fails here first.

### Load-test the reconnect path

N replays with N tickers, at speed 1, with a stream dropped every few seconds. The recipe is three calls and works against any simulator, Compose included:

```bash
curl -X POST $B/_replay/start   -d '{"market":"KXBTC15M-26AUG010000-00","copies":20,"speed":1,"rebase":true}'
# point one socket at each of KXBTC15M-26AUG010000-00-1 … -20, then, on a timer:
curl -X POST $B/_rystic/ws-drop -d '{"tickers":["KXBTC15M-26AUG010000-00-7"]}'
curl -s $B/_rystic/ws | jq '.connections | length'   # everyone else still there?
```

The number that matters is pace — tape time advanced per wall second: `1.00` means the simulator kept up at the requested speed; lower means it is applying frames slower than the tape plays them. That, not the socket count, is the ceiling. `GET /_replay/status` gives you tape position against wall time to compute it.

What one simulator sustained on a Mac mini (Apple M2, 8 cores, 8 GB), one dense 15-minute BTC tape (~630 book events/s per copy), a drop every 2 s, measured with Rystic's load rig:

| copies = sockets | frames/s delivered | pace | reconnect p50 |
|---|---|---|---|
| 10 | 5,900 | 0.98 | 153 ms |
| 25 | 7,500 | 0.45 | 841 ms |
| 50 | 13,700 | 0.45 | 179 ms |
| 100 | 3,600 | 0.05 | 266 ms |
| 200 | 2,500 | 0.02 | 1,309 ms |

1. Sockets are cheap: 200 open, all served, under 600 MB. The limit is applied tape events per second — roughly 6–7k/s on this box before the simulator falls behind the tape.
2. Sparser tapes go wider: a market printing 60 events/s sustains ten times the copies of one printing 630.
3. These historical reconnect figures measured from dial through subscription acknowledgment, not an opening snapshot; a current measurement runs from disconnect through the next acknowledgment and includes failed dial attempts. Measure on your own hardware before comparing reconnect latency.

Release notes: [Changelog](https://www.rystic.ai/docs/changelog.md#kalshi)

---

Source: https://www.rystic.ai/docs/simulators/kalshi · Markdown: https://www.rystic.ai/docs/simulators/kalshi.md
