Getting started
Backtesting with batch
rystic batch replays captured markets — tapes — one simulator per tape, your bot against each, -j at a time. Per tape: a ledger row, the simulator’s full end state, and both logs.
No SDK, no language requirement. Your bot reads RYSTIC_ADDR from the environment and trades over plain HTTP — the same calls as the live exchange.
Before you start
rystic pull kalshi-twin
rystic doctor
You also need tapes — a folder containing an events directory, one subfolder per market:
$ ls ~/data/kalshi-orderbook/events
KXBTC15M-26JUL231000-00 KXBTC15M-26JUL231015-00 ...
Those subfolder names are market ids. No tapes? Ask us for a sample archive, or record your own with the capture-setup.sh that ships with the simulator.
What your bot reads
| variable | what it is |
|---|---|
RYSTIC_ADDR | the simulator’s address for this run — host:port, no scheme. Trade against this. |
RYSTIC_MARKET | the market ticker being replayed — absent on a scenario run |
RYSTIC_WORLD | this run’s id |
RYSTIC_WORLD_KIND | tape or scenario, so a bot that handles both can tell which it is in |
RYSTIC_RESULT | a file path; write JSON here and it lands in your results row |
base = "http://" + os.environ["RYSTIC_ADDR"] # RYSTIC_ADDR is e.g. localhost:52114
That’s the whole integration: wherever your bot sets the Kalshi base URL, use http:// + RYSTIC_ADDR.
Exit 0 is a pass. Non-zero is recorded, not fatal — the run continues and the code is data in your results. A bot still running when the tape ends is stopped for you.
If your bot takes flags instead of environment variables
The same values reach your bot’s own command line. Anywhere after --, batch
substitutes {{addr}}, {{world}}, {{world_kind}}, {{result}} and
{{market}} per run:
rystic batch kalshi-twin -tapes ~/data/kalshi-orderbook -j 4 -out ./fleet \
-- python3 my-bot.py --market {{market}} --url {{addr}}
Type the braces literally — batch fills them in, one value per run. Without
them the arguments after -- are byte-identical in every slot, so -j 4 runs
four tapes against whichever single market you typed: three of the four wrong,
and nothing in the output says so.
-dry-run prints the resolved command for every tape, which is the cheapest
place to check all four before spending an hour of tape on one:
rystic batch kalshi-twin -tapes ~/data/kalshi-orderbook -dry-run \
-- python3 my-bot.py --market {{market}}
Three things it refuses rather than guessing:
- A name that isn’t one of the five — a misspelled
market, say. Refused before anything boots, because left alone it would reach your bot as those literal characters and the run would look like it worked. {{market}}where there is no market —rystic gridandbatch -scenariosrun the simulator’s own worlds, which aren’t a replayed market. The environment simply leavesRYSTIC_MARKETunset there; a command line has no way to say “unset”, and--market ""is an argument your bot can’t tell from a real one. Use{{world}}.- An image that predates this — under
--runner dockerthe substitution happens inside your image, so batch tells the container it needs arysticthat can do it. Too old an image fails its run naming the rebuild, rather than handing your bot the literal braces and finishing green.
{{market}} is the market ticker your tape folder is named for
(KXBTCD-26AUG2017-T73749.99). A bot that subscribes by event or series
derives those from it — market.rsplit("-", 1)[0] and
market.split("-", 1)[0] — because batch knows the folder name, not Kalshi’s
ticker grammar.
Your first backtest
Start with one tape and the bundled example bot, so every later problem is your bot’s, 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.25/random-walker.py
-tapesis the folder containingevents/;-tapeis one market id from it — drop it to run every tape;-outis where results land.- Everything before
--is for rystic, everything after is the command that starts your bot. The version in the path is yours to check —rystic listshows what’s installed.
The tape plays at real speed — a 15-minute market takes 15 minutes, with a heartbeat line per slot. Success:
batch: 1 tapes through kalshi-twin 0.0.25, 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
mtm marks open positions at the last recorded price; net is realized. A capture 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 re-renders the table any time, with -csv, -sort <column>, --json.
Then swap in your own bot: replace everything after -- with however you start it.
To run every tape, drop -tape and add -j:
rystic batch kalshi-twin -tapes ~/data/kalshi-orderbook -j 4 -out ./fleet -- python3 my-bot.py
-j 4 runs four tapes at once, each in its own simulator on its own port. Batch starts one copy of your bot per tape with RYSTIC_ADDR already pointing at that tape’s simulator — which is why the bot must read it rather than hardcode localhost:8080. The same goes for the market: pass {{market}} rather than a fixed ticker, or all four copies trade the one you typed.
Reading the results
Under -out:
ledger.jsonl— one line per tape: the market, how much played, how it settled, your bot’s exit code, anything it wrote toRYSTIC_RESULT, plus fingerprints of tape, simulator and bot. Two rows with the same fingerprints are the same experiment.state/— the simulator’s complete end state per tape.logs/— the simulator’s and your bot’s output, per tape.
The same thing in containers
Optional — for kernel isolation, CI, or handing the fleet to a test platform. Result files are identical.
Build an image with your bot inside:
FROM registry.rystic.ai/kalshi-twin:0.0.25
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 .
Your license (from rystic login) is what lets docker pull the base image. The USER/RUN lines install Python — our image doesn’t include it. A compiled bot needs only FROM and COPY, built for Linux and the container’s CPU.
Then the same batch command with two additions — and note the bot path is now the in-image path:
rystic batch kalshi-twin --runner docker --image kalshi-fleet:local \
-tapes ~/data/kalshi-orderbook -j 4 -out ./fleet -- python3 /work/my-bot.py
The license reaches each container through its environment, never the image. Workers are labelled, cleaned up when the run ends (Ctrl-C included), and never clutter rystic ps. Changed your bot? Re-run docker build — the image holds a copy, not a link.
When it refuses
| you saw | 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> . — no login fixes this |
bot did not start: fork/exec … no such file or directory | the bot path is a laptop path, not an in-image path | 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:<version> |
| a worker exits 2 with no results 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 {{market}} and friends, and the image predates them | rebuild the image FROM a version that has them |
| exit 8, runtime unavailable | docker isn’t running | start Docker, or drop --runner docker |
Still stuck? rystic doctor names the broken piece before a run has to discover it.