Simulators
Batch backtests
rystic batch replays every tape in a library through its own simulator, your bot against each, and ends in a per-market P&L table. Start with one tape and a bundled bot, so any later problem is your bot’s and not the setup’s:
rystic batch kalshi-twin -tapes ~/data/kalshi-orderbook -tape KXBTC15M-26JUL231000-00 -out ./fleet -- python3 ~/.rystic/twins/kalshi-twin/0.0.54/random-walker.py
-tape picks one market from the library, and -out is where results land. Everything before -- is for rystic, and everything after it is the command that starts your bot. rystic list shows the installed version in the bot’s path. These commands read files from the archive and the CLI install, so they’re shown as listings rather than examples the docs check runs.
The tape plays at real speed, so a 15-minute market takes 15 minutes, with a heartbeat line per slot. A finished run prints its table:
note: no -settlements file — markets stay open at tape end, so any P&L in the summary will EXCLUDE their outcomes
batch: 1 tapes through kalshi-twin 0.0.54, 2 slots → ./fleet
slot 1 ▶ KXBTC15M-26JUL231000-00 (7.0 MB)
slot 1 … KXBTC15M-26JUL231000-00 330476/478352 events (69%) · 8m46s · ~3m55s left
✓ KXBTC15M-26JUL231000-00 — 478352 events in 13m59s, bot exited 0 [1/1] · mtm 16.5556 · fees 2.9367
WORLD SETTLED BOT MTM NET FEES MARK TRADED FILLS POSITION BALANCE
KXBTC15M-26JUL231000-00 — exit 0 16.5556 10.2646 2.9367 8.9910 104.5620 17 -9.00 10010.2646
TOTAL 16.5556 10.2646 2.9367 8.9910 104.5620 17
1 worlds · 0 failed · 0 settled, 1 left open
⚠ 1 market was never settled — realized columns EXCLUDE their outcomes; mark-to-market columns value the open positions at the LAST RECORDED price, which settlement can still move by up to the position size.
Provide -settlements <file> (market → yes|no; outcomes come from the exchange after close, never from the tape) and re-run to realize them exactly.
1 done, 0 failed in 13m59s → fleet/ledger.jsonl
peak RSS of one twin-or-bot process, this process's lifetime: 61 MB (measured, not a budget)
MTM values open positions at the last recorded price, and NET is realized. A tape records the book, never how the market resolved. So a market left open has no outcome in its realized columns until -settlements <file> says how each one settled. rystic report ./fleet prints the table again at any time, with -csv, -sort <column> or --json.
Run your bot on every tape
Replace everything after -- with the command that starts your bot, drop -tape and add -j:
rystic batch kalshi-twin -tapes ~/data/kalshi-orderbook -j 4 -out ./fleet -- python3 my-bot.py --market {{market}}
-j 4 runs four tapes at once, each in its own simulator on its own port, with one copy of your bot per tape. That’s why the bot reads RYSTIC_ADDR or takes {{addr}} rather than hardcoding localhost:8080. The bot contract lists every value a run hands your bot.
Tapes
-tapes takes a folder holding an events directory with one subfolder per market, named for its market ticker, in the tape format. Tapes are too large to ship in the release. Ask Rystic for a sample archive, or write your own from the process that reads Kalshi’s WebSocket feed.
The archive’s capture-setup.sh sorts capture files you already have into that layout. It records nothing:
bash ~/.rystic/twins/kalshi-twin/0.0.54/capture-setup.sh ~/Downloads/captures
It copies every .ndjson.gz under the paths you pass to events/<market_ticker>/ under $KALSHI_CAPTURE_DIR (default ~/data/kalshi-orderbook). The ticker comes from each file’s first record, so a file needn’t be named for its market. A file whose name is already in that market’s folder counts as in place and isn’t copied, even if its contents differ. A file that isn’t gzip is skipped and named.
Reading the results
Under -out:
ledger.jsonlholds one line per tape: the market, how much of the tape played, how it settled, your bot’s exit code, whatever it wrote toRYSTIC_RESULT, and fingerprints of the tape, simulator and bot. Matching fingerprints mean the same tape, simulator and bot file. They don’t cover the bot’s environment or-settlements, so compare those too.state/holds the simulator’s complete end state per tape.logs/holds the simulator’s and your bot’s output per tape.
Each tape in its own container
For kernel isolation and no shared ports, the shape that ports to any fleet, put your bot and its runtime on the Rystic image and hand batch the result:
FROM registry.rystic.ai/kalshi-twin:<ver>
USER root
RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*
USER rystic
COPY my-bot.py /work/my-bot.py
docker build --pull -t kalshi-fleet:local .
rystic batch kalshi-twin --runner docker --image kalshi-fleet:local -tapes ~/data/kalshi-orderbook -j 4 -out ./fleet -- python3 /work/my-bot.py
- The
USERandRUNlines install Python, which the image doesn’t include. A compiled bot needs onlyFROMandCOPY, built for Linux and the container’s CPU. --pulltakes the currentFROMimage rather than a cached one.kalshi-fleet:localis yours, built on this machine and never pulled, so a fresh machine needs thedocker buildbefore its first fleetbatch. Without it, Docker falls through to Docker Hub and returnsrepository does not exist or may require 'docker login', which no login fixes.- Your bot runs inside the container, so name it by its in-image path (the workdir is
/work). The image holds a copy of your bot, not a link: after changing it, rundocker buildagain. - Each run’s container runs
rystic batch -tape <id>inside your image, so the bot sees the same contract as natively. Its row, state and logs are copied back under-outwhen it exits, andlogs/<world>.container.logis the container’s own output. - The key or license reaches each container only through its environment. The workers are labeled
ai.rystic.runner=batch, never appear inrystic psand are removed when the run ends, Ctrl-C included.docker ps -a --filter label=ai.rystic.runner=batchshows any a killed run left, and the next run sweeps them. --imagemust be built from a Rystic image of the same product. The outer CLI needs a localrystic pullfor the simulator’s manifest, as native batch does. Tapes play at speed 1 by design, so throughput is-j.- Serving works under any uid, so
--user 1000:1000in a hardened CI runner is fine.rystic doctorinside such a container reports the read-only install as a warning, not a failure.
When batch refuses
| You see | It means | Do this |
|---|---|---|
no tapes in <dir> match "events/*/*.ndjson.gz" | -tapes points at the wrong folder | point it at the folder that contains events/ |
<image> names no registry, so it must already exist on this machine (exit 5) | your --image was never built here | docker build --pull -t <image> ., since no login fixes this |
bot did not start: fork/exec … no such file or directory | the bot path is a path on your machine, not in the image | use the path from your COPY line |
the BOT column says exit 2 and the bot’s log says can't open file | the script after python3 is a path on your machine, not in the image | use the path from your COPY line |
image <name> is built for <other>, not kalshi-twin | the FROM line names another product | rebuild FROM registry.rystic.ai/kalshi-twin:<ver> |
| a worker exits 2 with no ledger row | the image was built from a stale base | rebuild with docker build --pull |
a row says the image’s rystic is older and would have passed the braces through | your bot’s arguments use placeholders, and the image predates them | rebuild the image FROM a version that has them |
| exit 8, runtime unavailable | Docker is not running | start Docker, or drop --runner docker |
rystic doctor names a broken piece before a run has to discover it.
Backtest a bot on a recorded day
Replay one tape by hand when you want to look at a single run:
curl -s "$RYSTIC_URL/_replay/markets"
curl -s -X POST "$RYSTIC_URL/_replay/start" -H 'Content-Type: application/json' -d '{"market":"KXBTC15M-26AUG010000-00","speed":1,"rebase":true}'
curl -s "$RYSTIC_URL/trade-api/v2/portfolio/fills"
curl -s "$RYSTIC_URL/_rystic/egress?tail=100"
speed: 1 is the measurement setting: a bot on the wall clock sees the market it would have seen live. Read the fills for what it got done, and the egress log for the frames it saw. Tape replay covers the rest of the controls.