Simulators
Tape replay
Tape replay plays a recorded Kalshi session through the simulator, so your bot trades against a book that moves the way the real one did. Point KALSHI_CAPTURE_DIR at a capture archive (default ~/data/kalshi-orderbook). The simulator plays each recorded book delta, trade and ticker as a real WebSocket frame, on the same sequence your own orders use. The simulator clock tracks tape time, and the book advances around your resting orders: replay never touches an order you placed.
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name replay --tapes ~/tapes --tape KXBTC15M-26AUG010000-00 --speed 5 --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_replay/markets"
curl -s -X POST "$RYSTIC_URL/_replay/pause"
curl -s -X POST "$RYSTIC_URL/_replay/play" -d '{"speed":20}'
curl -s "$RYSTIC_URL/_replay/status"
rystic run --tapes <dir> (or RYSTIC_TAPES) sets the archive and checks the library against the simulator’s manifest before start. --tape <id> starts replaying that market once the simulator serves. Over the API, POST /_replay/start takes the same choices:
curl -s -X POST "$RYSTIC_URL/_replay/start" -H 'Content-Type: application/json' -d '{"market":"KXBTC15M-26AUG010000-00","speed":5,"rebase":true}'
curl -s -X POST "$RYSTIC_URL/_replay/seek" -d '{"position":80000}'
- Recorded flow fills your resting orders. A replayed print that exceeds the volume ahead of your quote in the level queue fills you, at a maker fee of 0. The fill moves your position and balance and pushes
fill,user_ordersandmarket_positionsframes, the path your crossing orders use. A neworderbook_deltasubscription gets anorderbook_snapshotfirst, your resting orders included. Tape format, market records and replay limits says what the fill model leaves out. - Playback paces at the recorded gaps, divided by
speed.status.modereportsapply. rebaseshifts the tape onto wall time. Leave it on: a bot that computes market hours from the wall clock reads an un-rebased tape as expired.seekis exact in both directions, because frames are complete snapshots. But it reseeds wholesale, so don’t scrub mid-backtest.positionis an event index into the tape (the first event is0), not a time.POST /_replay/prepareparses the tapes and returns204without playing them. It takes the samemarket,marketsandcopiesasstart. A large tape’s parse is the slow part, so prepare at boot. Astartthat names the same markets in the same order, with the samecopies, then skips it. The parse is used once.GET /_replay/stream?market=<id>returns a tape as NDJSON without touching what is playing. Each line is one event:position,time_ms,kindand the event’s fields. Name the tape as/_replay/marketslists it, not a copy’s ticker.
GET /_replay/status reports where playback stands:
| Field | What it holds |
|---|---|
mode | the playback mode |
position | the next event’s index |
position_ms, duration_ms | the tape time played, and the tape’s total |
rebase_offset_ms | how far rebase shifted the tape’s timestamps onto wall time, fixed for the run |
inject | the loaded schedule’s counts, absent with none |
To replay several markets on one clock, see Several markets in one replay.
Start paused, stop at an exact time
POST /_replay/start loads the tapes and starts playing in the same call. To load a group paused, so you can read rebase_offset_ms before it plays, give start a checkpoint at the session’s first instant. That’s start_ms, the tapes’ earliest shared delta:
curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"markets":["A","B"],"speed":1,"rebase":true,"checkpoints":[1793746800001]}'
until curl -s "$RYSTIC_URL/_replay/status" | jq -e '.at_checkpoint_ms' >/dev/null; do sleep 0.5; done
curl -s "$RYSTIC_URL/_replay/status" | jq '{playing, at_checkpoint_ms, start_ms, rebase_offset_ms}'
# -> {"playing":false,"at_checkpoint_ms":1793746800001,"start_ms":1793746800001,...}
curl -s -X POST "$RYSTIC_URL/_replay/play"
start returns 204 as the group starts playing toward the checkpoint, so wait for at_checkpoint_ms before you read the offset or play. Events stamped at that instant apply before the pause.
pause stops wherever the wall clock happens to be. To stop at a known instant, give the player a checkpoint in absolute tape milliseconds, on the axis status reports start_ms on, before any rebase. It applies every event stamped at or before that time and none after, then pauses with the head exactly there:
curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"market":"KXFEDDECISION-26OCT-H0","speed":20,"checkpoints":[1790000000000,1790003600000]}'
curl -s -X POST "$RYSTIC_URL/_replay/play" -d '{"until_ms":1790007200000}'
curl -s "$RYSTIC_URL/_replay/status" | jq '{at_checkpoint_ms, checkpoints, position_ms}'
# -> {"at_checkpoint_ms":1790000000000,"checkpoints":[1790003600000,1790007200000],...}
A checkpoint is a pause point: the head stops there with playing: false until the next play. What lands before a stop depends only on the tape and the stop, never on speed or a retune along the way. So the same requests at the same checkpoints after the same reset give the same fills and balances. Plain play resumes toward the next pending stop, and a seek drops the stops behind its new head.
| You send | The simulator returns |
|---|---|
an until_ms behind the head | 409 |
| a checkpoint before the tape’s first event | 400 |
| either one in a live session | a refusal |
A harness that steps the tape itself needs no absolute times. Two relative stops take the same path:
curl -s -X POST "$RYSTIC_URL/_replay/play" -d '{"advance_ms":20000}'
curl -s -X POST "$RYSTIC_URL/_replay/play" -d '{"until":"next_event"}'
curl -s "$RYSTIC_URL/_replay/status" | jq '{at_checkpoint_ms, next_event_ms}'
advance_ms plays that much tape from the head. until: next_event pauses right after the next scheduled injected event and its tape events, with nothing later applied. status.next_event_ms names the stop it would take. With no event pending, until: next_event returns 409. Every injected event due inside a step fires at its own time before the pause. So GET /_feed, read after the step, holds exactly the lines through the new head. Both stops return 409 on a finished tape (start it again) and in a live session.
Script events on tape time
POST /_replay/start takes inject, the path of an inject file on the simulator’s filesystem. Its at values are tape time: the axis of start_ms, where a +<duration> counts from the tape’s first event. Each event fires when replay reaches that instant. The clock reads tape time plus rebase_offset_ms, which is wall time at start minus the tape’s first event, fixed for the run. Scripted events describes the file.
curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"market":"<tape>","speed":1,"rebase":true,"inject":"/data/inject.jsonl"}'
curl -s "$RYSTIC_URL/_replay/status" | jq -c .inject
# -> {"loaded":3,"tape_clock":true,"fired":0,"calls":1,"messages":1,"registers":{"state_live_data":{"entries":1,"keys":1}}}
- The file loads and is checked before the first frame. A bad file returns
400and nothing starts. It needsstate_set, as--injectdoes, and a live-mode start refuses it. - Play-again shifts the same schedule onto the new offset. So does a later
startwithoutinjectthat names the same market selection: the same markets in the same order, with the samecopiesandas. Any otherstart, or a live-mode start, unloads it. - A reset re-arms the schedule. On the next play, every event before the replay head fires at once, rebuilding the state the reset cleared.
- On a rebased run, each seed’s timestamps move by
rebase_offset_mswhen it fires, keeping their format and precision. An empty or unparsable value lands as written. Instate_live_datathe shifted field isdetails.last_updated. Instate_milestonethey’restart_date,end_dateandlast_updated_ts. rystic run --tape <t> --inject <file>sends the file this way, and under--tape,RYSTIC_INJECTis the default for--inject.rystic uptape services default the same way. Without--tape,--injectis a boot schedule on the simulator clock.- A backward
seekdoesn’t fire again an event that already fired.
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 in place of the lease volume, as the Docker page shows. Omit --copies for the tape’s original ticker. With three copies, the markets are <recorded-ticker>-1, -2 and -3.
No environment variable sets --tape, --speed, --copies or --seed-rng, so they go in command. --seed-rng pins the simulator’s RNG, as POST /_rystic/seed-rng does. Select live feed or tape replay for a run, not both. Before you mount an archive read-only, read where a replayed market’s metadata comes from.