Simulators
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:
| Area | What you get |
|---|---|
| Portfolio | orders (create / amend / decrease / cancel / batch), fills, positions, settlements, balance |
| Markets, events, series | catalog and orderbook reads |
| Exchange | status, schedule, announcements |
| Communications | RFQs and quotes |
| Historical | markets, orders, fills, trades, candlesticks |
| Account & keys | limits, volume progress, endpoint costs, API keys |
| The rest | search, 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.
| Channel | Notes |
|---|---|
orderbook_delta | opens with an orderbook_snapshot on the first touch of a market |
ticker, trade | market data |
fill, market_positions, user_orders | private — scoped to the connecting actor |
market_lifecycle_v2 | accepted, 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:
X-Rystic-Actor— the explicit override, for harnesses that name their actor.- The service's API-key header (
KALSHI-ACCESS-KEYhere) — 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. 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:
| Endpoint | What it does |
|---|---|
GET /_rystic/version | health check + baked model hash; on a licensed build, also the build watermark, licensee, and license expiry |
POST /_rystic/reset | wipe all state |
POST /_rystic/seed | load a state snapshot |
POST /_rystic/clock | set or advance the clock |
POST /_rystic/seed-rng | fix the RNG |
GET /_rystic/inspect | dump the entire state |
GET /_rystic/egress | WS messages your orders triggered, in order |
GET /_rystic/registers | list seedable families and fields |
Two seed shapes:
-
Scenario file (boot via
RYSTIC_SEED) — aseedwrapper 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 } -
POST /_rystic/seed— the register map only, no wrapper:jq .seed scenario.json | curl -X POST .../_rystic/seed -d @-. Anunknown 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:
- 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. rebaseshifts 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.seekseeds 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
- 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.
- No counterfactual response. Recorded takers don't react to your quotes — no market impact, no one fading you.
- Without a tape the book is frozen. It only moves when something trades
against it; there's no ambient market noise. Replay or
/_rystic/seedare the two ways to move it. market_lifecycle_v2is silent. Subscriptions are accepted but never emit — lifecycle changes are exchange-side events a client can't cause.- One process = one exchange world — run multiple ports for isolated books.