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
}
HTTP_BASE_URLandWS_BASE_URLare the simulator’s address for REST and the WebSocket feed.REQUEST_*sign REST calls andSTREAMING_*the WebSocket upgrade; any RSA key works (see Auth).SUBACCOUNTis Kalshi’s subaccount number,0the primary.- 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. - The key id is an identity:
membershares the default account with headerless curl. Any other id trades in its own portfolio — and a plaincurlthen readsmember’s (see Multiple bots). - Nothing at
GET /trade-api/v2/portfolio/ordersmeans 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
- Free and Pro run on your API key:
rystic login rys_…stores the key, and a containerrystic runstarts getsRYSTIC_TOKEN. The simulator checks in withlease.rystic.aiwhile it runs and stops when your plan’s time runs out (What stops a simulator). A Compose file you keep setsRYSTIC_TOKEN: ${RYSTIC_TOKEN}and mounts arystic-leasesvolume, as the examples below do. - 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_LICENSEat the path — swap it in wherever the examples below sayRYSTIC_TOKEN, and drop the lease volume.GET /_rystic/versionreports the licensee and expiry. LICENSE.txtgoverns 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.
| 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 |
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:
| 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) |
GET /_rystic/egress | WS frames your orders triggered, in order (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) and injected rejects (Network profiles) |
GET /_rystic/ws | open WebSocket connections and their subscriptions |
POST /_rystic/ws-drop | cut connections by ticker, id, or all — see WebSocket feed |
/_rystic/ui | the 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.
| 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) |
| 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}
payload.msgis the frame body; assert on that. The gateway numbers each subscription separately, so a client’ssidandseqdiffer from the ones recorded here.- The log is not actor-scoped. A private frame carries
owner, and the gateway delivers it to that account alone; this endpoint returns every frame. A two-bot run sees both bots’ fills, so filter onowner. Public market data has none. - The log keeps 100,000 frames by default — minutes of a dense replay tape. Check
droppedbefore trusting it as complete.
Determinism & reset
- 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.
btc15m_tapeplays 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/scenariowith{"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.
- Without
Content-Type: application/jsonit answers400settle requires ticker and result (yes|no). - The market becomes
finalizedon an empty book (yes_bid 0.0000,yes_ask 1.0000) with the recordedexpiration_valuepublished: a later order create answers404 market_not_found; cancels still work. Onbtc15m_tapethat is the scenario’s only market: the tape’s later frames leave the settled market alone, so it stays empty untilPOST /_rystic/scenarioputs the scenario back. - Idempotent: the same result again is a
200no-op with"already_settled":true; the opposite result is409 settlement_conflict; an unknown ticker is404. market_lifecycle_v2subscribers receivedetermined, thensettled.
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
- 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.
- No counterfactual response. Recorded takers don’t react to your quotes.
- 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_runandbtc15m_tapedo.chop,pinned_at_fiftyandbtc15m_tapeloop forever; the others stop at the end of their tape.degrades_mid_run,heals_after_outageandsession_expires_mid_runchange the network over time, not the book. - Lifecycle frames are the simulator’s own.
market_lifecycle_v2sendscreatedwhen the simulator mints a market anddeterminedthensettledon/_settle; a scheduled close sends nothing, as on live. The price feeds (pyth_value,cfbenchmarks_value) are silent on a tape. - One process holds one exchange. Run multiple ports for isolated books.
- The historical archive ships empty except in
has_history. Every/historical/*route answers, but onlyhas_historyseeds the registers behind them — elsewherehistorical/cutoffreads epoch and the archived markets, orders, fills, trades and candlesticks come back 404 or empty. To backtest against your own archive, seed thestate_historical_*registers and the cutoff through/_rystic/seed(GET /_rystic/registerslists their required fields). btc15m_tapecarries 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.- 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/scheduledoes 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 itsscheduleenvelope, 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_indexGET /trade-api/v2/historical/fills(200):fills/*/exchange_indexGET /trade-api/v2/portfolio/fills(200):fills/*/exchange_indexGET /trade-api/v2/portfolio/positions(200):market_positions/*/exchange_indexGET /trade-api/v2/portfolio/settlements(200):settlements/*/exchange_indexGET /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
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 |
- Protocol matches live:
subscribe/unsubscribe/update_subscription, asubscribedconfirmation carrying each channel’ssid,{type, sid[, seq], msg}, 10 s serverheartbeat. - A tape frame arrives as incremental
orderbook_deltaframes plus atickerupdate when the quote moved. - 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}'
- Select by
tickers(any socket whose subscription names one), byconnections(ids fromGET /_rystic/ws), orall. A selector that matches nothing answers{"dropped":[]}. - Default is a bare close with no close frame — what a crashed server or a severed path looks like.
graceful: truesends1001 going awayfirst. - Sockets are split by ticker, so drop one stream and assert the others survive:
GET /_rystic/wsafterwards lists what is still open. - 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.
- A balance seeded without
balance_breakdownis a one-shard account on shard 0. An order on a market on another shard answers404 user_not_found, as on the exchange. - 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, andGET /trade-api/v2/portfolio/balance?exchange_index=2shows 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]
}}}
- One cash pool: start the simulator with
KALSHI_REPLAY_SHARD=0and 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 theexchange_indexyou give it.
Multiple bots
Every identity gets its own portfolio. Precedence, on REST and the WS upgrade alike:
X-Rystic-Actor— the explicit override.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.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
- 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. - Replayed flow fills your resting orders — at maker fee 0, moving position and balance, pushing
fill/user_orders/market_positions. rebaseshifts the tape onto wall time. Leave it on; a bot computing market hours from the wall clock reads an un-rebased tape as expired.seekis exact in both directions — frames are complete snapshots. Don’t scrub mid-backtest.positionis an event index into the tape (the first event is0), not a time.statusreportsmode;position, the next event’s index;position_msandduration_ms, tape time played and in total;rebase_offset_ms, how farrebaseshifted the tape’s timestamps onto wall time (wall time at start minus the tape’s first event, fixed for the run); andinject, the loaded schedule’s counts, absent with none.injectonstartnames an inject file on the simulator’s filesystem,{"events":[…]}or one event per line. Itsatvalues are tape time: each event fires when replay reaches that instant, and on a rebased run a seed’s timestamps shift byrebase_offset_msas it fires. A bad file answers400and nothing starts; play-again re-shifts the schedule; a backwardseekdoes not fire an event again.rystic run --tape <id> --inject <file>sends it this way.POST /_replay/preparetakes the samemarket,marketsandcopiesasstart, parses the tapes and answers204without playing them. A large tape’s parse is the slow part, so prepare at boot and astartnaming the same markets, in the same order with the samecopies, skips it; the parse is used once.GET /_replay/stream?market=<id>returns a tape, named as_replay/marketslists it (not a copy’s ticker), as NDJSON, one line per event (position,time_ms,kindand 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}}}
recv_msis when your process read the frame, Unix ms.eventis the message, unmodified.orderbook_snapshot,orderbook_deltaandtradeare replayed; the simulator renders ticker frames from them. Any other type, a line that isn’t JSON and a delta whosesideis neitheryesnornoare skipped and counted in the simulator log, so record only those three.- 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. - The clock is the exchange’s
msg.ts_ms. A snapshot has none and takes the next stamped line’s;recv_msis used only when no line in the tape has a stamp. A stamp that steps backwards is held at the one before it. - Day files play in filename order, so a session that crosses midnight UTC is one tape. Any name ending
.ndjson.gzworks if the names sort in time order. - 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",…}}
datais themarketobject fromGET /trade-api/v2/markets/{ticker}, verbatim. Strike, title and price grid are served from it.- Write it when you start recording a market. With several records for one market, the earliest
recv_mswins. - 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}'
copies: Nnames them<tape>-1…<tape>-N;{"tape", "as"}entries inmarketslet you pick the names yourself. Mix them with plain tapes freely.- REST and WS agree on the new ticker:
markets,orderbook,fills,positionsand every frame carry it.statusshows whichtapeeach member plays. - The real record (strike, title, grid) carries over from the tape’s own ticker; the alias mints its own
tickerandevent_ticker. - Each copy is folded separately, so memory scales with copies × tape size. Prefer short captures for wide fan-outs.
rystic run kalshi-twin --tape <id> --copies 20does 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
- 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.
- Pinned bounds the blast radius: any other destination is refused before the connection is made.
- Even unset, the simulator speaks only
https, never follows a redirect off its starting host, and logs the host it dialed. - What it costs to leave on: replaying a tape tells the exchange which historical window you are studying.
- The market is still replayed as it was — lifecycle from the fetched record is never copied.
- 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,…}
- No key setup. The CLI carries a read-only exchange key —
rystic doctor kalshi-twinshowslive feed … built into this release.KALSHI_KEY_ID(orKALSHI_KEY_ID_FILE, a path to a file holding it) plusKALSHI_PRIVATE_KEY_FILE(a path) orKALSHI_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_idand/run/secrets/kalshi_private_key— see In Compose. - 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 Npre-carries the busiest N;--live-markets A,Bcarries exactly those;--live-events E1,E2carries every open market of those events;--live-series KXBTC15M,KXETH15Mnarrows the offer to those series. Naming markets or events turns--live-carryoff: the names are the whole selection, and the feeder log says the carry is ignored. Every--live-*flag also readsRYSTIC_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. lag_msis 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 inrystic logs --feed <name>splits it:upstream_lag_msis receive time minus the exchange stamp, so alag_msthat tracks it is the exchange running late, and one well above it is the feeder or the simulator falling behind;post_msis each batch’s round trip to the simulator, apply included.- 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.
- Paper-trading ceiling. Your orders fill against the real book and prints but never move them, and queue position is what replay already claims.
- What changes from replay: there is no tape, so no
total,positionorduration_ms—statuscarriesframes,dropped,last_ms,lag_ms.seekanswers 409,pausedrops frames instead of queuing them,speedis 1. - 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_timeand the expiration fields are the exchange’s own. - Delivery is best-effort. A slow simulator costs frames (
droppedin the[status]line, with the queue depth asqueuedandpeak), 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 ingapsandlost.reconnectscounts sockets the exchange dropped, anderrors=25:3counts the exchange’s error frames by code. - 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 anorderbook_snapshot— only matters if you hand-roll a feeder. - The carried list is
/_replay/watch, the same list the Markets card edits, so a script can change it at runtime.GETanswers{"available":[…],"watch":[…],"seen":[…]}: the feeder’s offer, the picks, and the markets that frames have arrived for; an empty list isnull.POST {"markets":["A","B"]}replaces the picks (204), and the feeder resubscribes within about two seconds;{"markets":[]}clears them. Only markets inavailableare 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 aPOSTcan add to them but not drop them for good. With nothing named,--live-carry Nrefills an empty list with the busiest N. A body naming neithermarketsnoravailableanswers400, and aPOSToutside a live session409. Events have no HTTP form: name them at startup with--live-eventsorRYSTIC_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
| 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).
- The exchange socket lives in the CLI process — under
--runner dockertoo, 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. - Your own key. A release build carries a read-only exchange key;
doctorreports it asbuilt into this release.KALSHI_KEY_ID(orKALSHI_KEY_ID_FILE, a path to a file holding it) plusKALSHI_PRIVATE_KEY_FILE(a path) orKALSHI_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_idand/run/secrets/kalshi_private_key, both or neither. The environment wins over/run/secrets, and/run/secretsover the built-in key. Neither is your Rystic API key or license. - Only a simulator whose manifest declares a feed takes
--live— todaykalshi-twin.rystic featureslists it as live feed. stopends the feeder first, then the simulator;restartre-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
- 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.
- 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. - 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.
- Events add to
RYSTIC_LIVE_MARKETSor its file; set both to carry named markets as well.RYSTIC_LIVE_EVENTSand the events file are alternatives, like their market equivalents. --live-seriesdoes not narrow named events: each one is looked up directly.- Like the markets file, the events file is read once at startup. A CLI
rystic restartreuses the resolved event list. - 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 = 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 |
- 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.
- Sparser tapes go wider: a market printing 60 events/s sustains ten times the copies of one printing 630.
- 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