Skip to Content
SimulatorsKalshiSeveral markets in one replay

Simulators

Several markets in one replay

Replay several markets in one simulator, so their books move against each other on one port and one clock. Name a list of tapes from the same session, for example BTC, gold and silver:

export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name group --tapes ~/data/kalshi-orderbook --json | jq -r .data.url)
curl -s -X POST "$RYSTIC_URL/_replay/prepare" -d '{"markets":["KXBTC15M-26AUG202330-30","KXSILVER15M-26AUG202330-30","KXGOLD15M-26AUG202330-30"]}'
curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"markets":["KXBTC15M-26AUG202330-30","KXSILVER15M-26AUG202330-30","KXGOLD15M-26AUG202330-30"],"speed":1,"rebase":true}'

From the CLI, rystic run --tape with the same comma-separated list prepares and starts the group itself. Tape replay covers the rest of the controls.

Replay correlated markets together

The tapes are merged, not synchronized. Their events interleave into one time-ordered stream that one play head drives, so alignment is exact by construction. Three separate simulators on three ports would be three clocks. And rebase on each would snap each tape’s own first event to now, erasing the offsets between the markets. Ordering inside a millisecond follows the order you listed the markets in. Within one tape, the recorded order is preserved.

  • The window is trimmed to the overlap. Playback covers [max(start), min(end)] across the group, and a market’s events outside it are dropped. Outside it, some other market has no tape and would look like a book that stopped trading. A market whose capture starts before the window enters it seeded with the book its trimmed prefix built, so the earlier starter isn’t one-sided. GET /_replay/status reports the window and each market’s share of it.
  • One quiet market narrows the whole session. A tape’s span runs from its first delta or trade to its last, and its opening snapshot doesn’t extend it. So one quiet market narrows the session to its own few events. When markets should share one window, give every tape a zero-size delta (delta_fp "0.00") at the session’s first and last instant.
  • Captures from different sessions have no overlap, so the group is refused with a 409 naming both spans.
  • Memory is per tape, not per window. A tape is folded whole before its span is known. So three full days cost three full days of heap (about 285 MB each), even when the shared window is minutes. Capture and group tighter sessions rather than trimming wide ones.

GET /_replay/markets carries a spans array for every tape the simulator has folded so far. So you can see which captures overlap before you ask for a group.

Play games back to back

A group plays markets that ran at the same time. A sequence plays games that didn’t, cut from different days, one after another in an order you choose, on one session clock:

curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"sequence":["GAME-A-MKT",["GAME-B-HOME","GAME-B-AWAY"],"GAME-C-MKT"],"gap_ms":60000,"speed":1}'

Each entry is one game: a market, or a list of markets merged like a group. Game 1 starts at its own window’s start (or session_start_ms), and each next game starts gap_ms after the previous window ends. Everything a game carries shifts by the same amount. That’s its book and trades, its market’s open, close and expiration times, and the clock its fills and trades are stamped on. A game plays identically wherever it sits in the order.

  1. Point the simulator at a library. KALSHI_CAPTURE_DIR may hold one cut archive per game (<dir>/<game>/manifest.json, events/, meta/). The simulator finds each market in the one archive that holds it, and refuses a market found in two.
  2. Shift the schedule the same way. Scripted events fire on the session clock, so a game’s feed lines and its settle calls must shift onto it. GET /_replay/status reports each game’s start_ms and offset_ms under sequence, plus current_game, so you can check they agree. A grader reads the same games from GET /_rystic/config: replay.session.sequence, with each game’s recorded_start_ms and recorded_end_ms. It maps a session time t to recorded time t minus offset_ms. replay.provenance names the cut archive each tape came from.
  3. One game at a time. A game’s markets appear when its window starts, minted from its first frame, and upcoming games aren’t listed before then. A game’s settle, scheduled at its final out, finalizes its markets before the next game opens.
  4. Steps cross games. Checkpoints, until_ms, advance_ms and until: "next_event" all run on the session clock. seek returns 409 in a sequence, because reseeding would reopen games that have settled: start the sequence again instead.
  5. Or stitch the set offline. tapecut stitch -dst <set> <cut>… writes the games already moved onto the session clock, one cut archive each, plus a session.json whose replay_start body plays them. Every offset_ms is then 0, and the files read as one continuous tape without the simulator.

Play one tape as many markets

Sockets are split by ticker, so replaying one tape N times is N runs on one ticker and one socket. copies plays the same tape as N distinct markets on one clock, each under its own ticker and on its own stream:

curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"market":"KXBTC15M-26AUG010000-00","copies":20,"speed":1,"rebase":true}'
curl -s "$RYSTIC_URL/_replay/status" | jq -c '[.markets[].market] | [first, last]'
# -> ["KXBTC15M-26AUG010000-00-1","KXBTC15M-26AUG010000-00-20"]
curl -s -X POST "$RYSTIC_URL/_replay/start" -d '{"markets":[{"tape":"KXBTC15M-26AUG010000-00","as":"KXLOAD-A"},{"tape":"KXBTC15M-26AUG010000-00","as":"KXLOAD-B"}],"speed":1}'
  • copies: N names them <tape>-1 through <tape>-N. {"tape", "as"} entries in markets let you pick the names, and they mix with plain tapes freely. From the CLI, rystic run kalshi-twin --tape <id> --copies 20 does the same.
  • REST and WebSocket agree on the new ticker: markets, orderbook, fills, positions and every frame carry it. status shows which tape each member plays.
  • The real record (strike, title, grid) carries over from the tape’s own ticker. The alias mints its own ticker and event_ticker.
  • Each copy is folded separately, so memory scales with copies times tape size. Prefer short captures for wide fan-outs.

Load-test a reconnect path

Start N copies at speed 1, point one socket at each ticker and drop a stream every few seconds with POST /_rystic/ws-drop. The number that matters is pace: the tape time advanced per wall second. 1.00 means the simulator kept up at the requested speed, and lower means it applies frames slower than the tape plays them. GET /_replay/status gives you tape position against wall time to compute it.

What one simulator sustained on a Mac mini (Apple M2, 8 cores, 8 GB), with one dense 15-minute BTC tape (about 630 book events a second per copy) and a drop every 2 seconds:

Copies = socketsFrames per second deliveredPaceReconnect p50
105,9000.98153 ms
257,5000.45841 ms
5013,7000.45179 ms
1003,6000.05266 ms
2002,5000.021,309 ms

Sockets are cheap: 200 open and served stay under 600 MB. The limit is applied tape events per second, roughly 6,000 to 7,000 a second on this machine before the simulator falls behind the tape. So sparser tapes go wider: a market printing 60 events a second sustains ten times the copies of one printing 630. Sockets on one market share the work. Each message is encoded once for all of them, and each socket gets its pending messages in one write.

The reconnect figures measure from dial through the subscription acknowledgment, not an opening snapshot. So measure on your own hardware before you compare reconnect latency.

Last updated on