Skip to Content
SimulatorsKalshiTape replay

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_orders and market_positions frames, the path your crossing orders use. A new orderbook_delta subscription gets an orderbook_snapshot first, 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.mode reports apply.
  • rebase shifts 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.
  • seek is exact in both directions, because frames are complete snapshots. But it reseeds wholesale, so don’t scrub mid-backtest. position is an event index into the tape (the first event is 0), not a time.
  • POST /_replay/prepare parses the tapes and returns 204 without playing them. It takes the same market, markets and copies as start. A large tape’s parse is the slow part, so prepare at boot. A start that names the same markets in the same order, with the same copies, 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, kind and the event’s fields. Name the tape as /_replay/markets lists it, not a copy’s ticker.

GET /_replay/status reports where playback stands:

FieldWhat it holds
modethe playback mode
positionthe next event’s index
position_ms, duration_msthe tape time played, and the tape’s total
rebase_offset_mshow far rebase shifted the tape’s timestamps onto wall time, fixed for the run
injectthe 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 sendThe simulator returns
an until_ms behind the head409
a checkpoint before the tape’s first event400
either one in a live sessiona 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 400 and nothing starts. It needs state_set, as --inject does, and a live-mode start refuses it.
  • Play-again shifts the same schedule onto the new offset. So does a later start without inject that names the same market selection: the same markets in the same order, with the same copies and as. Any other start, 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_ms when it fires, keeping their format and precision. An empty or unparsable value lands as written. In state_live_data the shifted field is details.last_updated. In state_milestone they’re start_date, end_date and last_updated_ts.
  • rystic run --tape <t> --inject <file> sends the file this way, and under --tape, RYSTIC_INJECT is the default for --inject. rystic up tape services default the same way. Without --tape, --inject is a boot schedule on the simulator clock.
  • A backward seek doesn’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.

Last updated on