Skip to Content

Simulators

Kalshi simulator

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

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). Plain docker pull and Compose: Docker.

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:

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:

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). Every plan serves all 113 Trade API routes, the WebSocket feed, the matching engine, POST /_settle, 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

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:

{
  "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.

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:

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). 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 . 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.

113 routes, counted from the simulator’s literal registrations.

ResourceOperations
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

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. Kalshi adds two mounts in its own vocabulary, /_settle and /_replay/, and these routes carry Kalshi specifics:

RouteWhat it does
GET /_rystic/routesevery Trade API route this build serves, plus POST /_settle; not the control plane
POST /_settleresolve a market (Settling a market)
GET /_rystic/egressWS frames your orders triggered, in order (Egress log)
GET, POST /_rystic/networkthe 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) and injected rejects (Network profiles)
GET /_rystic/wsopen WebSocket connections and their subscriptions
POST /_rystic/ws-dropcut connections by ticker, id, or all — see WebSocket feed
/_rystic/uithe state UI
/_replay/markets, prepare, start, play, pause, seek, status, stream, feed, watch — replay and live mode; prepare and stream are under replay

Scenarios

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

CategoryScenarios
Recordedbtc15m_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)
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 covers listing and loading scenarios.

Seeding state

Scenarios 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:

{
  "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 covers reading the log.

{"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.
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 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:

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

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:

OperationStatusWhy
DELETE /trade-api/v2/api_keys/{api_key}400a well-formed request does not provoke it
DELETE /trade-api/v2/communications/quotes/{quote_id}204the operation is deprecated
DELETE /trade-api/v2/communications/quotes/{quote_id}404the operation is deprecated
DELETE /trade-api/v2/communications/rfqs/{rfq_id}400a well-formed request does not provoke it
DELETE /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}204nothing the twin serves creates the record it needs
DELETE /trade-api/v2/portfolio/events/orders429no request a client sends provokes it
DELETE /trade-api/v2/portfolio/events/orders/batched400a well-formed request does not provoke it
GET /trade-api/v2/communications/quotes/{quote_id}200the operation is deprecated
GET /trade-api/v2/communications/quotes/{quote_id}400the operation is deprecated
GET /trade-api/v2/communications/quotes/{quote_id}404the operation is deprecated
GET /trade-api/v2/communications/rfqs/{rfq_id}/quotes/{quote_id}200nothing the twin serves creates the record it needs
GET /trade-api/v2/events400no known request reaches it
GET /trade-api/v2/events/multivariate400no known request reaches it
GET /trade-api/v2/fcm/orders200no known request reaches it
GET /trade-api/v2/fcm/orders400a well-formed request does not provoke it
GET /trade-api/v2/fcm/positions200no known request reaches it
GET /trade-api/v2/fcm/positions400a well-formed request does not provoke it
GET /trade-api/v2/historical/fills400no known request reaches it
GET /trade-api/v2/historical/markets/{ticker}/candlesticks200no known request reaches it
GET /trade-api/v2/historical/orders400no 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

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 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 /_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
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.

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.

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 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:
{"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]
}}}
  1. 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.
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.

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
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:

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

{"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); 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:

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.

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, fills, 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:

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). Declare rystic-leases under the file’s top-level volumes:, as in Docker → 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 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), it is the only outbound call the simulator makes; an Enterprise simulator’s license check stays local.

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

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.

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.
  2. Nothing is carried until you pick. The feeder offers the 500 busiest open markets to the Markets card in the 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 — 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), 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.
curl -s localhost:8080/_replay/watch | jq '{watch, seen}'
curl -X POST localhost:8080/_replay/watch -d '{"markets":["KXBTC15M-26AUG010000-00"]}'

Flags and environment

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
FlagEnvDefaultMeaning
--liveRYSTIC_LIVE=1offfeed the simulator from the exchange
--live-carry NRYSTIC_LIVE_CARRY0pre-carry the busiest N markets; 0 carries nothing until the state UI picks; ignored when markets or events are named
--live-markets a,bRYSTIC_LIVE_MARKETS—carry exactly these markets before anything is picked; the state UI can change the list later
--live-markets-file pathRYSTIC_LIVE_MARKETS_FILE—read the startup watch list from a shared file; alternative to --live-markets
--live-events a,bRYSTIC_LIVE_EVENTS—carry every open market of these events, looked up again on every rescan; adds to --live-markets
--live-events-file pathRYSTIC_LIVE_EVENTS_FILE—read the startup event list from a shared file; alternative to --live-events
--live-series a,bRYSTIC_LIVE_SERIES*series to carry; * offers every open market on the exchange
--live-max-markets NRYSTIC_LIVE_MAX_MARKETS500how many of the busiest open markets the state UI offers
--live-shards NRYSTIC_LIVE_SHARDS3exchange sockets the carried markets spread over, max 5
--live-envRYSTIC_LIVE_ENVthe 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

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:

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:

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:

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:

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:

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:

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):

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:

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:

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:

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 } } }'
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:

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

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:

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

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:

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 = socketsframes/s deliveredpacereconnect p50
105,9000.98153 ms
257,5000.45841 ms
5013,7000.45179 ms
1003,6000.05266 ms
2002,5000.021,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

Last updated on