Simulators
Sample bots, the grid and the bot contract
The release archive ships four standalone bots, two smoke tests, an API check and a tape helper. The CLI runs a bot of yours across a grid of scenarios, one fresh simulator per cell, or across every tape in a library (Batch backtests). The commands below read files from the archive and the CLI install, so they’re shown as listings rather than examples the docs check runs.
Smoke tests
cd ~/.rystic/twins/kalshi-twin/*/
bash check.sh
bash trade-check.sh
python3 api-check.py http://localhost:8080
check.sh boots its own simulator on your API key (or Enterprise license), seeds a market and reads it back. It generates a throwaway twin_key.pem beside the binary when it can, which needs openssl and a writable directory.
trade-check.sh seeds a book, crosses it fill-or-kill and asserts fill, position, depletion and WebSocket egress. Pass host:port as $1 if the simulator isn’t on :8080. It calls POST /_rystic/reset and seeds its own book. So the running scenario, balances, orders and egress are gone afterward: post your scenario again.
api-check.py also resets the simulator, seeds its own state and walks the Trade API: catalog reads, the order lifecycle, RFQs and quotes, order groups, subaccounts and API keys. It prints one ✓ or ✗ line per check, exits 1 if any fails and needs openssl. The tape helper, capture-setup.sh, is under Tapes. The container image carries the smoke tests and the bots, but not api-check.py, capture-setup.sh or the archive’s scenarios/ folder.
Example bots
Four standalone scripts ship in the archive, standard library only, with nothing to install. Each takes the base URL as its first argument, defaulting to $RYSTIC_ADDR, then http://localhost:8080. The ones that take a market take its ticker second, defaulting to the simulator’s first active market. balance-watcher watches the account, not a market:
python3 simple-bot.py
python3 book-reader.py
python3 balance-watcher.py
python3 random-walker.py
simple-bot.pybuys 1 YES contract at the best ask every second, immediate-or-cancel: the canary.book-reader.pyplaces nothing. It polls the book twice a second and prints the top of book and the mid. It warns if a poll returns a status other than 200, a price leaves (0, 1) or the book crosses.balance-watcher.pyplaces nothing. Every two seconds it prints balance, positions, resting orders and fills on one line, so you can watch them move beside your own bot.random-walker.pyplaces 40 random orders. It exits 0 if every response was a 2xx or a 4xx the API documents, never a 5xx or a hang. Its form israndom-walker.py [base-url] [ticker] [orders] [seed], and it replays exactly under a pinned seed, so a failure reruns identically.
simple-bot, book-reader and balance-watcher run until Ctrl-C unless RYSTIC_TICKS caps the run (rystic grid sets it, and you can too). So start one next to your own bot. On btc15m_tape the walker prints a split such as 40 orders -> 201×25, 400×8, 409×7: accepted, malformed and refused. A simulator that took all 40 wouldn’t be modeling an exchange. On a moving tape the counts vary from run to run, and on a static scenario such as tight_book they repeat exactly.
The grid
rystic grid runs bots by scenario matrix: every bundled bot, plus your own if you pass one, each against the simulator’s declared scenarios, with one fresh simulator per cell.
rystic grid
rystic grid -worlds all
rystic grid -- python3 my-bot.py
| Command | Runs |
|---|---|
rystic grid | the bundled bots across the declared scenarios: tight_book, thin_book, empty_book, chop, trend_up and flash_crash |
rystic grid -worlds all | the bundled bots across every scenario the simulator carries |
rystic grid -- python3 my-bot.py | the bundled bots, plus your bot as another row |
The grid prints each cell, exits non-zero if any cell failed or never ran and writes per-cell logs to grid-<timestamp>/<bot>/logs/.
| A cell shows | When it |
|---|---|
| a check mark | passed |
✗exit | exited with a failure |
! | hit a slot error |
· | never ran |
Your bot keeps the bot contract: read RYSTIC_ADDR, and RYSTIC_WORLD if you care which scenario you’re in, and exit 0 for a pass. The grid also hands every cell RYSTIC_TICKS (-ticks, default 15). The bundled bots read it as their tick budget, and yours can too.
The bot contract
The grid and rystic batch pass each run’s bot the values below, both as environment variables and as placeholders they substitute into the bot’s own argv. A scenario run, such as every grid cell, has no market, so it gets all but RYSTIC_MARKET:
| Variable | Placeholder | What it is |
|---|---|---|
RYSTIC_ADDR | {{addr}} | this run’s simulator as host:port, with no scheme: point your Kalshi base URL at http:// plus this |
RYSTIC_MARKET | {{market}} | the market ticker the tape folder is named for; unset on a scenario run |
RYSTIC_WORLD | {{world}} | this run’s id |
RYSTIC_WORLD_KIND | {{world_kind}} | tape or scenario, for a bot that handles both |
RYSTIC_RESULT | {{result}} | a file path: JSON your bot writes there lands in its ledger row |
PORT | {{port}} | a loopback port no other run’s bot holds, for anything your bot serves |
Exit 0 is a pass. A non-zero exit is recorded in the ledger, not fatal, and the batch carries on. A bot still running when its tape ends gets SIGTERM, then SIGKILL five seconds later.
Each run’s bot gets its own arguments. Type the braces literally after --, and batch fills them in per run:
rystic batch kalshi-twin -tapes ~/data/kalshi-orderbook -j 4 -- python3 bot.py --market {{market}} --addr {{addr}}
Without placeholders, the argv after -- is exec’d byte-identical in every slot. So -j 4 runs four tapes against whichever single ticker you typed. -dry-run prints the resolved command for each run, so you can read the four before spending an hour of tapes on one.
| You pass | What happens |
|---|---|
| an unrecognized placeholder | refused before anything boots, so a typo can’t become a run that looks like it worked |
{{market}} on rystic grid or batch -scenarios | refused, because a scenario has no market and your bot can’t tell --market "" from a real one: use {{world}} there |
Deriving the event and series tickers. batch is service-blind. It knows the tape’s directory name, not Kalshi’s ticker grammar, so a bot that subscribes by event or series derives them itself. For every family with shipped tapes, the market ticker is <series>-<event date>-<strike>:
market = os.environ["RYSTIC_MARKET"] # KXBTCD-26AUG2017-T73749.99
event = market.rsplit("-", 1)[0] # KXBTCD-26AUG2017
series = market.split("-", 1)[0] # KXBTCD
Check it against your own library. An id that’s already an event ticker (KXDEMO-26) has no strike segment to drop, and rsplit would hand you the series. rystic batch -dry-run prints the resolved command for every run, the cheapest place to see that.