Skip to Content
SimulatorsKalshiSample bots, the grid and the bot contract

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.py buys 1 YES contract at the best ask every second, immediate-or-cancel: the canary.
  • book-reader.py places 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.py places 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.py places 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 is random-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
CommandRuns
rystic gridthe bundled bots across the declared scenarios: tight_book, thin_book, empty_book, chop, trend_up and flash_crash
rystic grid -worlds allthe bundled bots across every scenario the simulator carries
rystic grid -- python3 my-bot.pythe 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 showsWhen it
a check markpassed
✗exitexited 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:

VariablePlaceholderWhat 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 passWhat happens
an unrecognized placeholderrefused before anything boots, so a typo can’t become a run that looks like it worked
{{market}} on rystic grid or batch -scenariosrefused, 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.

Last updated on