# Tape replay

Plays a captured market day through the matching engine on the exchange clock, with your resting orders filled by the recorded flow.

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.

```bash
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:

```bash
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](https://www.rystic.ai/docs/simulators/kalshi/features/tape-format.md#what-replay-does-not-model) 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:

| 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](https://www.rystic.ai/docs/simulators/kalshi/features/replay-groups.md).

### 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:

```bash
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:

```bash
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:

```bash
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](https://www.rystic.ai/docs/simulators/kalshi/features/scripted-events.md) describes the file.

```bash
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:

```yaml
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](https://www.rystic.ai/docs/docker.md#compose) 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](https://www.rystic.ai/docs/simulators/kalshi/features/tape-format.md#where-a-replayed-markets-metadata-comes-from).

---

Source: https://www.rystic.ai/docs/simulators/kalshi/features/tape-replay · Markdown: https://www.rystic.ai/docs/simulators/kalshi/features/tape-replay.md
