# Tape format, market records and replay limits

What a tape file holds, where a replayed market's record comes from, the one call to Kalshi that fetches it and what replay doesn't model.

A tape is a folder of recorded Kalshi WebSocket frames, and the simulator replays it as it is. To check a tape, replay it and read its market back:

```bash
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name tapecheck --tapes <tapes> --tape <MARKET_TICKER> --json | jq -r .data.url)
curl -s "$RYSTIC_URL/trade-api/v2/markets/<MARKET_TICKER>" | jq -c '.market | {floor_strike, strike_type}'
rystic stop tapecheck
```

[Tape replay](https://www.rystic.ai/docs/simulators/kalshi/features/tape-replay.md) covers the playback controls.

### Tape format

Write tapes from your own process. Only `events/` is required:

```text
<tapes>/
  events/<MARKET_TICKER>/<YYYY-MM-DD>.ndjson.gz   # required: one file per market per UTC day
  meta/<any-name>.ndjson.gz                       # optional: market records
```

**`events/`** holds gzipped NDJSON. Each line wraps one Kalshi WebSocket message exactly as received:

```json
{"recv_ms":1787234444328,"event":{"type":"orderbook_delta","sid":1,"seq":3261604,"msg":{"market_ticker":"KXBTCD-26AUG2011-T71599.99","market_id":"2fb58232-c672-4527-bf81-cf892b4e8762","price_dollars":"0.5000","delta_fp":"-274.00","side":"yes","ts":"2026-08-20T14:00:44.292945Z","ts_ms":1787234444292}}}
```

1. `recv_ms` is when your process read the frame, in Unix milliseconds. `event` is the message, unmodified.
2. `orderbook_snapshot`, `orderbook_delta` and `trade` are replayed, and the simulator renders ticker frames from them. It skips, and counts in its log, any other type, a line that isn't JSON and a delta whose `side` is neither `yes` nor `no`. So record only those three.
3. **Start each market with an `orderbook_snapshot`.** Kalshi sends one on subscribe and on every resubscribe, so log from the subscribe. A delta before any snapshot lands on an empty book.
4. The clock is the exchange's `msg.ts_ms`. A snapshot has none and takes the next stamped line's. `recv_ms` is used only when no line in the tape has a stamp. A stamp that steps backwards is held at the one before it.
5. Day files play in filename order, so a session that crosses midnight UTC is one tape. Any name ending `.ndjson.gz` works if the names sort in time order.
6. Appending gzip members to an open file is fine. A file that stops mid-stream ends the tape at its last whole line, skips any later files and logs it. If nothing was read before the break, the replay fails instead.

**`meta/`** is optional and holds one line per market record:

```json
{"recv_ms":1787234444000,"kind":"market","key":"KXBTCD-26AUG2011-T71599.99","data":{"ticker":"KXBTCD-26AUG2011-T71599.99","floor_strike":71599.99,"strike_type":"greater"}}
```

1. `data` is the `market` object from `GET /trade-api/v2/markets/{ticker}`, verbatim. Strike, title and price grid are served from it. With several records for one market, the earliest `recv_ms` wins.
2. An archive that also carries the event's own record sets `mutually_exclusive` from it. That's a line `{"recv_ms":…,"kind":"event","key":"<event_ticker>","data":{…,"mutually_exclusive":true}}`. Otherwise a replayed market's event is minted around it with `mutually_exclusive: false`, and nothing fetches an event record.
3. Without a market record, the simulator fetches one, as the next section describes.

Nothing else is needed: no index and no CSV. Leave out a `manifest.json` at the library root. The simulator reads one as a capture-window manifest, which must then list every market you replay.

### Where a replayed market's metadata comes from

A capture holds a market's book and its prints, never the market record. The quote, sizes, last price and volume derive from the tape. Everything else comes from the market's own record: the declared grid, `floor_strike`, `strike_type`, `custom_strike`, `title` and the rules text. On a threshold market the strike is what the market means: `KXBTCD-26AUG2017-T73749.99` is `floor_strike 73749.99` with `strike_type "greater"`.

The simulator gets that record from one of two places:

- **Your capture archive**, when its `meta/` holds one. Nothing is fetched.
- **Kalshi**, otherwise. The first time you replay that tape, the simulator makes one unauthenticated `GET /markets/{ticker}` to the exchange, with no API key involved. When the library is writable, it writes the result into the archive's `meta/`, so the call happens once per tape. With a read-only library, it fetches again on every run.

This is the only outbound call the simulator makes, besides the Pro check-in with Rystic ([what it sends](https://www.rystic.ai/docs/licensing.md#what-a-running-simulator-sends)). An Enterprise simulator's license check stays local.

The market is still replayed as it was: active, with a live book. While the market trades, the simulator never copies the record's ending: `status: finalized`, `result`, `settlement_value_dollars` and `expiration_value`. A settled market with a moving book couldn't exist upstream, and `expiration_value` before settlement is look-ahead in a backtest. The record's expiration times are re-derived on the tape's own clock. [Settling the market](https://www.rystic.ai/docs/simulators/kalshi/features/settlement.md) publishes its `expiration_value` and empties the book.

`RYSTIC_REPLAY_METADATA=off` turns the fetch off, and `RYSTIC_REPLAY_METADATA_HOSTS` pins the hosts it may dial:

```bash
RYSTIC_REPLAY_METADATA=off ./kalshi-twin
RYSTIC_REPLAY_METADATA_HOSTS=api.elections.kalshi.com ./kalshi-twin
```

1. **Off costs the strike fields and nothing else.** The grid is synthesized from the series, strike metadata is absent and the simulator logs which market it couldn't describe.
2. **The hosts setting bounds where it may dial.** Any other destination is refused before the connection is made. Even with it unset, the simulator speaks only `https`, never follows a redirect off its starting host and logs the host it dialed.
3. **Leaving it on tells the exchange which historical window you're studying**, one request per tape.
4. **A failed or disabled fetch never fails a replay.** Blocked egress degrades your markets quietly, and nothing else goes red.

For parity without the call, best first: ship `meta/` with your tapes (a current capture writes it), seed the market's record before you start the replay, or take the loss with `off`.

### What replay does not model

- **Fills at your quoted level are a lower bound.** Your queue position advances on recorded trades that consumed liquidity ahead of you, and on cancels that provably came from ahead of you. The tape gives each cancel's size but never its place in the queue. So a cancel is placed behind you first, and moves you only by the quantity that wouldn't fit there.
- **5 ms is the model's one empirical parameter.** A removal splits into traded and canceled parts by pairing each print with the level deltas beside it within 5 ms. On the reference archive, 99.9% of traded quantity pairs. On a reconnect snapshot, the liquidity ahead of you stays ahead, bounded by the snapshot's size. `GET /portfolio/orders/{id}/queue_position` reports the same queue the fill model fills against.
- **A quote priced better than the recorded flow is not a lower bound.** It fills against the cumulative recorded flow that crosses it, and each print of a multi-level sweep can fill it again. That assumes every recorded taker would have routed to your price. The flow is shared, never duplicated: all your quotes at or inside a price together fill at most what recorded takers traded at or through it.
- **Recorded takers don't react to your quotes.** Your fills print to the public `trade` feed at your price, under the same `trade_id` as your private `fill` frames. The recorded print shrinks by the quantity you displaced, so per-event public volume always matches the recording. The tape you observe includes your own trades.
- **Liquidity you take stays taken.** Recorded book events carry absolute level sizes, so each level carries a debt: what you, or any other order not on the tape, took there that the recording hasn't yet accounted for. The level shows the recorded size minus the debt. A recorded decrease pays the debt down before it removes anything else, its canceled part first. So a cancel of liquidity you already took never moves your queue.
- **Nothing else pays the debt.** A recorded increase never pays it, and nothing decays it with time or distance from the touch. A restart, a seek or `POST /_rystic/reset` starts every level debt-free.
- **The bundled `btc15m_tape` carries the recorder's silences.** Its session was captured with a recorder backlog. So its exchange timestamps hold three gaps of about four minutes across a fourteen-minute window, and the book freezes for minutes between bursts.

---

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