Skip to Content

Simulators

Kalshi simulator

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.

Screenshots

Or drive it yourself in the live demo.

The trading terminal — book, tape, ticket, and positions.
The trading terminal — book, tape, ticket, and positions.
The order book: live depth, your resting orders marked.
The order book: live depth, your resting orders marked.
The state UI at /_rystic/ui — market, bots, replay, book.
The state UI at /_rystic/ui — market, bots, replay, book.
Per-bot panels: position, exposure, session P&L, fills.
Per-bot panels: position, exposure, session P&L, fills.
Replay: pick a market, set speed, rebase, play or scrub.
Replay: pick a market, set speed, rebase, play or scrub.
The activity bus during a captured-day replay.
The activity bus during a captured-day replay.

Quickstart

Builds are in limited release — request access for a download link.

curl -fsSL "https://www.rystic.ai/api/install?l=…" | sh   # CLI + license
rystic run kalshi-twin                         # pulls it, serves :8080
rystic run kalshi-twin -d --port 9000          # detached, port of your choosing
rystic run kalshi-twin --scenario thin_book    # -- --list-scenarios for the menu
rystic run kalshi-twin --seed scenarios/01_no_arb.json
rystic run kalshi-twin --tapes ~/data/kalshi-orderbook --tape <market>
rystic run kalshi-twin --live                  # a book tracking the exchange right now
rystic run kalshi-twin --runner docker -d

Holding a license file instead of a link: rystic login ./rystic-license.json first (Installation). Plain docker pull and compose: Docker.

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

Seeds a book, crosses it fill-or-kill, asserts fill, position, depletion and WS egress. Idempotent. check.sh, HOWTO.md, SCENARIOS.md and scenarios/ sit beside it.

Point your bot at it — configuration only, no code changes:

{
  "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. Credentials supplied directly means the SSM/boto3 path never runs — no cloud account needed.
  2. 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 empty one (see Multiple bots).
  3. Nothing at GET /trade-api/v2/portfolio/orders means the bot isn’t reaching the simulator.
Coverage

113 Trade API v2 routes in the current build, none stubbed, plus the WebSocket feed and the matching engine.

AreaWhat you get
Portfolioorders (create / amend / decrease / cancel / batch), fills, positions, settlements, balance
Markets, events, seriescatalog and orderbook reads
Exchangestatus, schedule, user data timestamp
CommunicationsRFQs and quotes
Historicalmarkets, orders, fills, trades, candlesticks — served from a seeded archive (see Limitations)
Account & keyslimits, volume progress, endpoint costs, API keys
The restsearch, milestones, live data, structured targets, multivariate collections, incentive programs

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

WebSocket feed

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

ChannelNotes
orderbook_deltaopens with an orderbook_snapshot on first touch of a market
ticker, trademarket data
fill, market_positions, user_ordersprivate — scoped to the connecting actor
market_lifecycle_v2accepted, never emits (see Limitations)
  1. Protocol matches live: subscribe / unsubscribe / update_subscription, per-channel sids, {type, sid[, seq], msg}, 10s server heartbeat.
  2. 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 localhost:8080/_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 localhost:8080/_rystic/ws-drop -d '{"tickers":["KXBTC15M-26AUG010000-00"]}'
# {"dropped":[1]}
curl -X POST localhost:8080/_rystic/ws-drop -d '{"connections":[2,3]}'
curl -X POST localhost:8080/_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.
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.

{"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. total counts everything emitted since the last reset; events may hold fewer.
  4. The log keeps 100,000 frames by default — minutes of a dense replay tape. Beyond that the oldest drop and dropped counts them. RYSTIC_EGRESS_RETAIN=0 at boot or POST /_rystic/egress-retain {"retain":0} removes the bound, and GET /_rystic/version reports egress_retain. Check dropped before trusting the log as complete.
  5. POST /_rystic/reset clears it and restarts seq at 1.
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.

Auth

The simulator accepts anything, but a Kalshi client needs a real RSA key to sign with. check.sh generates a throwaway twin_key.pem beside the binary (needs openssl), or make your own; any key id works.

openssl genrsa -out twin_key.pem 2048
License
RYSTIC_LICENSE=/path/to/rystic-license.json ./kalshi-twin   # running the binary directly
  1. rystic login parks it and rystic run hands it over. In a container it arrives as RYSTIC_LICENSE=<the license itself>, never a layer or mount.
  2. Verification is offline and runs once at startup — a serving simulator is never interrupted.
  3. Starts for 7 days past expiry with a warning, then refuses.
  4. LICENSE.txt governs use; GET /_rystic/version reports licensee and expiry.
Seeding state
EndpointWhat it does
GET /_rystic/versionhealth check + baked model hash; licensed builds add watermark, licensee, expiry
POST /_rystic/resetwipe all state
POST /_rystic/seedload a state snapshot
POST /_rystic/scenarioreset, then apply a named world — GET /_rystic/scenarios for the catalogue
POST /_rystic/clockset or advance the clock
POST /_rystic/seed-rngfix the RNG
GET /_rystic/inspectdump the entire state
GET /_rystic/egressWS messages your orders triggered, in order — ?tail=N for the last N
GET /_rystic/registerslist seedable families and fields
POST /_rystic/egress-retainbound the egress log (0 = unbounded)
POST /_rystic/networkper-endpoint latency shaping, off by default
GET /_rystic/wsopen WebSocket connections and their subscriptions
POST /_rystic/ws-dropcut connections by ticker, id, or all — see WebSocket feed

Two seed shapes:

  1. Scenario file, booted via RYSTIC_SEED — a seed wrapper plus determinism knobs.

    {
      "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
    }
    
  2. POST /_rystic/seed — the register map only, no wrapper.

    jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
    

Success returns 204; rejection names every valid register. unknown register "seed" means you posted the wrapped file.

A record needs its whole required field set. A short one isn’t rejected — it’s served as though whole, with WARNING — incomplete seed in the log, so it surfaces later as a client crashing on an absent key. GET /_rystic/registers lists what’s required; RYSTIC_SEED_GATE=reject makes it a refusal. 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.

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.

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 both directions — frames are complete snapshots. Don’t scrub mid-backtest.
  5. status reports mode, position, position_ms, duration_ms, rebase_offset_ms.

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

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. It is the only outbound call the simulator makes — the license check included 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 synthesised 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 dialled.
  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), seed the market’s record before starting replay, or take the loss with off.

Live mode

See Docker configuration for a feeder running inside the container and a ticker file shared with your bot.

The simulator can be fed instead of played. 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                        # 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 plus KALSHI_PRIVATE_KEY_FILE (a path) or KALSHI_PRIVATE_KEY_B64 (the PEM, base64) use your own key instead; the id and one key form.
  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-series KXBTC15M,KXETH15M narrows the offer to those series. 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. A climbing lag_ms means nothing is feeding.
  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_msstatus 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 — counted in rystic logs --feed <name> — and a dropped delta leaves that market’s book behind until its next snapshot.
  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.

Flags, their environment variables and the feed command: The rystic CLI → Live feed.

Determinism & reset
curl -X POST localhost:8080/_rystic/reset
jq .seed scenarios/01_no_arb.json | curl -X POST localhost:8080/_rystic/seed -d @-
# ... run your algo for N seconds ...
curl -s localhost:8080/trade-api/v2/portfolio/fills   # assert exact fills

Fills, position and balance deltas, and WS pushes follow your orders identically every run. If two runs differ, pin /_rystic/clock and /_rystic/seed-rng to isolate it. State is in-memory: restart = clean slate (plus RYSTIC_SEED).

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, activity bus — screenshots above.

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 — 16 of the 45 do, three loop forever.
  4. market_lifecycle_v2 is silent. Subscriptions accepted, never emitted.
  5. One process = one exchange world. Run multiple ports for isolated books.
  6. The historical archive ships empty. Every /historical/* route answers, but no shipped scenario seeds the registers behind them — historical/cutoff reads epoch and the archived markets, orders, fills, trades and candlesticks come back 404 or empty. To backtest against the archive today, seed the state_historical_* registers and the cutoff through /_rystic/seed (GET /_rystic/registers lists their required fields).
Cookbook

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 localhost:8080/_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 localhost:8080/_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 localhost:8080/trade-api/v2/portfolio/orders \
  -H 'Content-Type: application/json' -H 'X-Rystic-Actor: maker' \
  -d '{"ticker":"KXDEMO-26-A","client_order_id":"m1","action":"sell","side":"yes",
       "count":10,"type":"limit","yes_price_dollars":"0.40",
       "time_in_force":"good_till_canceled"}'
# -> 201, status: "resting", user_id: "maker"

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

curl -s -X POST localhost:8080/trade-api/v2/portfolio/orders \
  -H 'Content-Type: application/json' -H 'X-Rystic-Actor: taker' \
  -d '{"ticker":"KXDEMO-26-A","client_order_id":"t1","action":"buy","side":"yes",
       "count":10,"type":"limit","yes_price_dollars":"0.40",
       "time_in_force":"fill_or_kill"}'
# -> 201, status: "executed", fill_count_fp: "10.00", taker_fill_cost_dollars: "4.0000"

Read it back with the same header you traded under:

curl -s localhost:8080/trade-api/v2/portfolio/fills -H 'X-Rystic-Actor: maker'
# -> one fill, "is_taker": false
curl -s localhost:8080/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 localhost:8080/_replay/markets      # pick a captured ticker
curl -X POST localhost:8080/_replay/start \
  -H 'Content-Type: application/json' \
  -d '{"market":"KXBTC15M-26AUG010000-00","speed":1,"rebase":true}'
# ... run your bot ...
curl -s localhost:8080/trade-api/v2/portfolio/fills   # what it got done
curl -s 'localhost:8080/_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

curl -X POST localhost:8080/_rystic/reset
jq .seed scenarios/01_no_arb.json | curl -X POST localhost:8080/_rystic/seed -d @-
curl -X POST localhost:8080/_rystic/clock -d '{"time":"2026-07-23T00:00:00Z"}'
curl -X POST localhost:8080/_rystic/seed-rng -d '{"seed":42}'
# ... run your algo ...
curl -s localhost:8080/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 localhost:8080/_rystic/network -d '{"enabled":true}'
curl -s localhost:8080/_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–23ms p50, order create/cancel 12–13ms 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 rig lives at examples/kalshi-loadtest in the rystic-sim repo; the recipe is three calls and works against any twin, Compose included.

curl -X POST localhost:8080/_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 localhost:8080/_rystic/ws-drop -d '{"tickers":["KXBTC15M-26AUG010000-00-7"]}'
curl -s localhost:8080/_rystic/ws | jq '.connections | length'   # everyone else still there?
go run ./examples/kalshi-loadtest -url http://localhost:8080   -tape KXBTC15M-26AUG010000-00 -copies 20 -drop-every 5s -duration 60s
# sockets=20 frames/s=11804 pace=1.00 drops=3 reconnects=3   … then a JSON summary

pace is tape time advanced per wall second: 1.00 means the twin 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.

What one twin sustained on a Mac Mini (Apple M2, 8 cores, 8GB), one dense 15-minute BTC tape (~630 book events/s per copy), a drop every 2s:

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 600MB. The limit is applied tape events per second — roughly 6–7k/s on this box before the twin 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 dial through subscription acknowledgment, not an opening snapshot. The corrected rig measures from disconnect through the next acknowledgment, includes failed dial attempts, and counts fast reconnects independently of the drop response. Re-run it before comparing reconnect latency.
  4. Run the rig on your own hardware before trusting a number; it prints the table above for your machine.
Release notes

Newest first. GET /_rystic/version reports which build you’re running.

v0.0.26 — 2026-09-17

  1. Drop a WebSocket connection on demandGET /_rystic/ws lists every open socket with its subscriptions; POST /_rystic/ws-drop cuts them by tickers, by connections id, or all, bare by default (a crashed server, a severed path) or with 1001 going away under graceful: true. Sockets are split by ticker, so drop one stream and assert the others survive. The drop is recorded on the event stream as a control op.
  2. One tape as many marketscopies: N on /_replay/start plays one tape as N distinct markets on one clock, <tape>-1<tape>-N, each on its own stream; {"tape", "as"} entries in markets pick the names. REST and WS agree on the new ticker, the real record carries over from the tape’s own ticker, and status shows which tape each member plays. rystic run kalshi-twin --tape <id> --copies N does the same from the CLI.
  3. A load rig for the reconnect pathexamples/kalshi-loadtest in the rystic-sim repo drives N streams against N copies with a drop on a timer and reports frames/s, pace (tape time per wall second; 1.00 means the twin kept up) and reconnect latency.
  4. Every live setting from the environment--live reads RYSTIC_LIVE=1 and every --live-x-y flag reads RYSTIC_LIVE_X_Y, flag over env over default. New --live-markets a,b carries exactly those markets before the state UI picks; KALSHI_PRIVATE_KEY_B64 carries your own key inline beside KALSHI_PRIVATE_KEY_FILE; a stack file’s live: true service takes the live_* keys; rystic run --live --runner docker runs the feeder on the host beside the container.
  5. A scenario swap under a running tape is one step — moving to another scenario while a tape played could carry the previous world’s records into the new one. The swap now holds the tape off for its whole duration.
  6. A market always has its event — seeding a state_market_catalog record (or replaying over one you seeded) mints the state_event it names when none exists, so GET /events/{event_ticker} and GET /events?series_ticker= answer for every market the twin serves. A minted event carries its market’s exchange_index; an event you seed yourself is left as you seeded it.

v0.0.25 — 2026-09-12

  1. Pausing inside a quiet stretch of tape keeps your place — between two recorded events the play head paces the gap on the clock. POST /_replay/pause holds that paced position, play (and a speed change mid-flight) resumes from it instead of rewinding to the last applied event, and GET /_replay/status reports it while paused as well as while playing. Chunked playback — pause, play a little, pause again, the shape an agent-driven clock takes — could never cross a gap longer than one chunk before this, and a status read taken while paused mid-gap jumped a whole gap forward. An uninterrupted play is unchanged.

v0.0.24 — 2026-09-06

  1. The twin can be fed a live stream, not only a recorded taperystic run kalshi-twin --live starts the twin and a feeder together; rystic feed kalshi-twin --url <base> attaches a feeder to a twin already running, any runner, any machine. Pick the exchange with --live-env prod|demo, choose what is carried with --live-series and --live-carry, spread it with --live-shards. The key is checked before the twin starts — the CLI carries a read-only one from 0.0.20, and KALSHI_KEY_ID + KALSHI_PRIVATE_KEY_FILE use your own instead. The twin still dials nothing: the socket lives in the CLI process, frames arrive at POST /_replay/feed and take the same fold a tape frame takes. Not claimed: paper-trading fidelity — your orders fill against the fed book but never move it, and delivery is best-effort (dropped frames are counted and reported).
  2. The state UI carries a watch list — the Markets card lists what the feeder offers, busiest first; add, remove and watch several at once. The Replay card reports frames applied, frames dropped, markets carried, and lag, the age of the last frame — a climbing lag means nothing is feeding the twin.
  3. The clock steps; it no longer runs — simulated time advances one millisecond per request that reads it and stands still between requests, so the same sequence from the same reset renders byte-identical stamps, created_time and ts included. Nothing expires while the twin is idle: advance past the deadline with POST /_rystic/clock {"advance_seconds": N} (or pin it with {"time": ...}) and the next request cancels the order. Assert on ordering, never on elapsed time.

v0.0.23 — 2026-09-02

  1. The boot banner prints a URL you can pastehttp://[::]:8080 on the four boot lines was the wildcard bind, not a host, while the CLI’s summary said localhost:8080 for the same port. The banner rewrites "", :: and 0.0.0.0 to localhost; an explicit host survives. --health still answers 127.0.0.1 — a distroless image has no resolver.
  2. Two order-entry rejections come in live’s order and live’s words — a reused client_order_id answers 409 order_already_exists before the balance check, and a fill_or_kill the book cannot cover answers 409 fill_or_kill_insufficient_resting_volume. Both used to answer 400 insufficient_balance. good_till_canceled and post_only still answer insufficient_balance pre-match, as live does.
  3. The backtest half of the API answers--scenario has_history boots a world holding both halves: KXDEMO-26-A open and tradeable, KXDEMO-25-SETTLED archived behind a 2026-07-20 cutoff, each 404ing on the other’s half. Before it, /historical/* had no scenario to read and every world booted with a dead archive.
  4. Four reads answer the question asked — a candlestick’s end_period_ts is the inclusive end of its window; ?event_ticker= narrows both positions reads through the market’s catalog record; forecast_percentile_history is one series per event; total_cost_shares_fp counts seeded contracts once.

v0.0.22 — 2026-08-30

  1. Every world in a batch gets its own arguments{{world}}, {{world_kind}}, {{market}}, {{addr}} and {{result}} substitute into your bot’s argv; the four env vars are unchanged. An unrecognised placeholder is refused before anything boots; -dry-run prints the resolved command per world.
  2. Correlated tapes play as one exchange — pass markets: [...] to /_replay/prepare and start and the group merge-sorts into a single time-ordered stream: one simulator, one port, one play head, one rebase offset. Tapes that never overlap are refused with both spans named.
  3. Fills and orders carry the side/action fields live still servesaction_side on the WS fills channel, book_side and outcome_side on REST.
  4. 17 documented operations the simulator never registered now route, order entry enforces the bounds it declares, and list endpoints page.
  5. A miss answers as the exchange — Kalshi’s own error shape, not a generic one.
  6. Smaller surface corrections — block-trade accept requires a named party; expiration_time enforced as a deadline; an amend keeps what the order was created with.
  7. Replay accuracy — tape removals attributed to trades versus cancels with the queue advancing behind-first; the network profile re-measured from us-east-2.

v0.0.21 — 2026-08-27

  1. The exchange is sharded, the way the real one is — buying power, presence and routing are per shard (exchange_index on the order record), and the portfolio/intra_exchange_instance_transfers routes move cash between them. Cancels route too and default to shard 0: pass ?exchange_index=<n>, or ?exchange_index=-1&market_ticker=… to auto-route.
  2. Post-only meets your own quotes the way live does — a post_only order under self_trade_prevention_type: maker crossing only your own resting orders is accepted, cancelling them in full. Anyone else’s order in the region still answers the byte-identical 400 post only cross.
  3. One trade, one id, five surfaces — both parties’ REST fills, both private WS fill frames and the public trade print share a single id. Correlate by id, drop the heuristics.
  4. Position money at live’s scaleGET /portfolio/positions renders money at six decimals (“0.114600”), and each fill leg is recorded under its own party.
  5. A replay reseed no longer swallows WS frames in flight — pending frames are drained before and inside the reseed, so the stream is complete across tape snapshots.
  6. Batched creates are yours alone — the batched path stamps the caller’s identity exactly as the single-order path does.
  7. A fresh install has somewhere to go — scenarios are compiled into the binary and --list-scenarios names them, so the quickstart works with no tape archive.

v0.0.20 — 2026-08-21

  1. A replayed market knows what it isfloor_strike, cap_strike, strike_type, custom_strike, occurrence_datetime, real sub-titles, declared price grid. From your capture’s meta/ log, or fetched once per tape and cached.
  2. That fetch is yours to refuseRYSTIC_REPLAY_METADATA=off, or _HOSTS=<host> to pin it. See The one outbound call.
  3. Replayed as it was, not as it ended — status, result, settlement values and time fields stay derived from your tape.
  4. The price grid is per market, not per series. Read price_ranges per market.
  5. A simulator that dies says so at oncethe twin exited before serving: exit status 1.
  6. Shipped scenario packs checked field by field as they load.

v0.0.19 — 2026-08-20

  1. Backtest results you can readrystic batch ends in a per-market P&L table; rystic report <out-dir> re-renders it with -csv, -sort, --json.
  2. Honest mark-to-market — open positions at last traded price, zeroed at settlement. Never-settled markets called out; -settlements realizes them.
  3. Live progress while tapes play — heartbeat per slot, run-wide estimate on every completion.
  4. rystic doctor and the replay screen name -tapes <dir>, not an internal env var.

v0.0.18 — 2026-08-20

  1. Money folds in exact micro-dollars — no sub-cent fee drift over a long session, and the register never leaks onto the wire.
  2. A minted market declares the grid the exchange declares — full price_level_structure + price_ranges and a complete parent event.
  3. An unbuilt fleet image names the build, with the exact command, instead of Docker Hub’s login dead end.

Earlier

  1. v0.0.17 — same simulator as v0.0.16; first release cut and verified end to end by the automated pipeline.
  2. v0.0.16 — Ctrl-C stops a docker fleet; fleet failures name their cause; sharper docker errors.
  3. v0.0.15 — fleets from your own image (batch --runner docker --image); stack files; rystic export compose|antithesis.
  4. v0.0.14 — images pull from registry.rystic.ai with your license as the credential; the shipped Dockerfile retired.
  5. v0.0.13 — instances (run -d --name --port, ps, stop, restart, rm, logs, status), --json envelope, stable exit codes, rystic list, rystic grid.
  6. v0.0.12 — complete market records in shipped scenario packs; order creation validates required params as the outermost gate.
  7. v0.0.11 — full Kalshi field set on every scenario market, with a completeness gate; per-customer watermarked archives.
  8. v0.0.7 — named scenarios; evidence-backed fees and P&L; replay on the exchange clock; state UI; check accepts 204.
  9. v0.0.6 — first build shipped alongside the rystic CLI.
  10. v0.0.5 — replay 9.4× faster byte-for-byte; member fills print to the public tape; bounded egress log; network shaping.
  11. v0.0.4 — first licensed build: license gate, LICENSE.txt in every archive, rebased tapes surviving restarts.
  12. v0.0.2 — first limited release: Trade API v2, matching engine, WS feed, captured-day replay, state UI.
Last updated on