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/statusreports 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
409naming 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.
- Point the simulator at a library.
KALSHI_CAPTURE_DIRmay 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. - 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/statusreports each game’sstart_msandoffset_msundersequence, pluscurrent_game, so you can check they agree. A grader reads the same games fromGET /_rystic/config:replay.session.sequence, with each game’srecorded_start_msandrecorded_end_ms. It maps a session time t to recorded time t minusoffset_ms.replay.provenancenames the cut archive each tape came from. - 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.
- Steps cross games. Checkpoints,
until_ms,advance_msanduntil: "next_event"all run on the session clock.seekreturns409in a sequence, because reseeding would reopen games that have settled: start the sequence again instead. - Or stitch the set offline.
tapecut stitch -dst <set> <cut>…writes the games already moved onto the session clock, one cut archive each, plus asession.jsonwhosereplay_startbody plays them. Everyoffset_msis then0, 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: Nnames them<tape>-1through<tape>-N.{"tape", "as"}entries inmarketslet you pick the names, and they mix with plain tapes freely. From the CLI,rystic run kalshi-twin --tape <id> --copies 20does the same.- REST and WebSocket agree on the new ticker: markets, orderbook, fills, positions and every frame carry it.
statusshows whichtapeeach member plays. - The real record (strike, title, grid) carries over from the tape’s own ticker. The alias mints its own
tickerandevent_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 = sockets | Frames per second delivered | Pace | Reconnect p50 |
|---|---|---|---|
| 10 | 5,900 | 0.98 | 153 ms |
| 25 | 7,500 | 0.45 | 841 ms |
| 50 | 13,700 | 0.45 | 179 ms |
| 100 | 3,600 | 0.05 | 266 ms |
| 200 | 2,500 | 0.02 | 1,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.