rystic

Simulators

Kalshi simulator

Kalshi simulator

kalshi-twin is a local, deterministic Kalshi: the full Trade API v2 surface, the WebSocket feed, and a real price-time-priority matching engine, validated against Kalshi's demo exchange. Seed a known book — or replay a recorded market day — run your bot, get the same fills every run.

Quickstart

Builds are in limited release — request access to get the archives and your license file. Unpack into an empty directory (swap darwin_arm64 for darwin_amd64, linux_amd64, or linux_arm64; Windows ships as windows_amd64 / windows_arm64 zips):

mkdir kalshi-twin-test && cd kalshi-twin-test
mv ~/Downloads/kalshi-twin_0.0.4_darwin_arm64.tar.gz ~/Downloads/checksums.txt .
shasum -a 256 -c checksums.txt --ignore-missing
tar xzf kalshi-twin_0.0.4_darwin_arm64.tar.gz

Release builds are license-gated. Put the rystic-license.json we sent you next to the binary before the first run — details under License.

The archive ships with its own check scripts. This one starts the twin, seeds a market, reads it back, and prints ✓ or the failing step:

bash check.sh

Smoke-test a trade

With the twin running, exercise the matching engine end to end — seeds a resting book, crosses it with a fill-or-kill order, then asserts the fill, position, book depletion, and WS egress:

bash trade-check.sh

Idempotent — it resets first, so run it as often as you like. Pass a different host:port as $1 if the twin isn't on :8080. If you drive the order routes yourself, note they want Content-Type: application/json.

Run options:

./kalshi-twin                                              # serves :8080
RYSTIC_ADDR=:9000 RYSTIC_SEED=scenarios/01_no_arb.json ./kalshi-twin

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": "twin-local",
  "REQUEST_PEM_KEY": "<contents of twin_key.pem — see Auth>",
  "STREAMING_KEY_ID": "twin-local",
  "STREAMING_PEM_KEY": "<contents of twin_key.pem — see Auth>",
  "SUBACCOUNT": 0
}

Supplying credentials directly means the SSM/boto3 path never runs — no cloud account needed. Placed orders show at curl -s localhost:8080/trade-api/v2/portfolio/orders; nothing there means the bot isn't reaching the twin.

Coverage

The Trade API v2 surface — 88 routes — 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, announcements
CommunicationsRFQs and quotes
Historicalmarkets, orders, fills, trades, candlesticks
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

The twin serves Kalshi's WS surface at /trade-api/ws/v2. Point WS_BASE_URL at the twin and your streaming client connects, subscribes, and receives frames unchanged.

ChannelNotes
orderbook_deltaopens with an orderbook_snapshot on the 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)

The protocol matches the live feed: subscribe / unsubscribe / update_subscription commands, per-channel sids, {type, sid[, seq], msg} messages, and server heartbeat pings every 10s. Field sets match the live demo feed exactly, from captured traces.

Every frame stays queryable after the fact — GET /_rystic/egress returns the ordered history, and ?tail=N returns only the last N (worth using during a long replay).

Multiple bots

Every caller resolves to an identity, and each distinct identity gets its own isolated portfolio — separate orders, fills, positions, and balance. One precedence, applied on REST calls and on the WS upgrade alike:

  1. X-Rystic-Actor — the explicit override, for harnesses that name their actor.
  2. The service's API-key header (KALSHI-ACCESS-KEY here) — an unmodified bot that signs its requests gets its own account for free. The key id is read as a label, never verified; the twin still 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'

Reads and writes resolve the caller the same way, so a bot mints orders into the same partition it reads back, and its fill / user_orders / market_positions frames are scoped to it. Two bots on one twin can trade against each other. One gap: GET /portfolio/orders/{id} is still unscoped — a known id returns the order whoever owns it.

Auth

The twin accepts anything, but a Kalshi client needs a real RSA key to build its request signatures. The quickstart creates a throwaway twin_key.pem (openssl genrsa -out twin_key.pem 2048). Any key id works (e.g. twin-local).

License

Release builds are gated on a signed license file. Put rystic-license.json in the twin's working directory, or point RYSTIC_LICENSE at it:

RYSTIC_LICENSE=/path/to/rystic-license.json ./kalshi-twin

Verification is fully offline — signature and expiry, no network call, nothing leaves your machine, so the twin stays hermetic inside locked-down infrastructure. The check runs once at startup and nowhere else: a twin that is already serving is never interrupted, and a lapsed license bites on the next restart, not mid-run. For 7 days past expiry the twin still starts, with a renewal warning; after that it refuses to start.

Renewal is a new license file — same binary, no reinstall. LICENSE.txt in the archive is the agreement governing use, and GET /_rystic/version reports the licensee and expiry if you need to check what you're running.

Seeding state

The /_rystic/ control plane:

EndpointWhat it does
GET /_rystic/versionhealth check + baked model hash; on a licensed build, also the build watermark, licensee, and license expiry
POST /_rystic/resetwipe all state
POST /_rystic/seedload a state snapshot
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
GET /_rystic/registerslist seedable families and fields

Two seed shapes:

  1. Scenario file (boot via RYSTIC_SEED) — a seed wrapper plus optional determinism knobs:

    {
      "seed": {
        "state_market_catalog": { "DB-A": { "ticker": "DB-A", "status": "active",
          "yes_ask_dollars": "0.40", "no_ask_dollars": "0.55" } },
        "state_balance": { "member": { "balance": 10000 } }
      },
      "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 .../_rystic/seed -d @-. An unknown register "seed" error means you posted the wrapped file.

Success returns 204; rejection returns {"errors":[...]} naming every valid register.

Replay a captured market day

Point KALSHI_CAPTURE_DIR at a capture archive (default ~/data/kalshi-orderbook) and the twin can play 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

Every recorded book delta, trade, and ticker renders as a real WS frame on the same per-market sequence your own orders use, and the twin clock tracks tape time. Playback holds the recorded gaps between events, divided by speed — ×1 is true real time.

Three things worth knowing:

  1. Replayed flow fills your resting orders. A recorded print that exceeds the volume ahead of you in the level FIFO fills you at maker fee 0, moves your position and balance, and pushes fill / user_orders / market_positions. The book advances around your orders — replay never touches an order you placed.
  2. rebase shifts the tape onto wall time, so a session recorded last week starts now. A bot that computes market hours from the wall clock sees an un-rebased tape as already expired and won't quote. Leave it on unless you're only watching. Replaying from the top re-rebases to the new now — including a restart of the twin, which keeps the market catalog on the session's axis rather than the tape's.
  3. seek seeds wholesale — frames are complete book snapshots, so scrubbing is exact in both directions, but don't scrub mid-backtest.

status reports mode: "apply", the play head as both a frame position and position_ms of tape time, duration_ms, and rebase_offset_ms.

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. Byte-identical runs are the check — if they differ, the nondeterminism is in your algo; fix the twin's clock and RNG via /_rystic/clock and /_rystic/seed-rng to isolate it. State is in-memory: restart = clean slate (plus RYSTIC_SEED if set).

State UI

The twin serves a live monitor at localhost:8080/_rystic/ui — order book, per-bot P&L, order entry, captured-day replay, and the activity bus. Full tour: Kalshi state UI.

Limitations

  1. Passive fills are a deliberate lower bound. Queue position advances on replayed trades only — cancels ahead of you never advance it, because a capture can't attribute removals to fills vs cancels without double-counting. Real fills arrive no later than the twin's.
  2. No counterfactual response. Recorded takers don't react to your quotes — no market impact, no one fading you.
  3. Without a tape the book is frozen. It only moves when something trades against it; there's no ambient market noise. Replay or /_rystic/seed are the two ways to move it.
  4. market_lifecycle_v2 is silent. Subscriptions are accepted but never emit — lifecycle changes are exchange-side events a client can't cause.
  5. One process = one exchange world — run multiple ports for isolated books.