Getting started
Docker
Every simulator is published at registry.rystic.ai/<product>:<version>, and your license file is the docker credential.
Pull
docker login registry.rystic.ai -u license --password-stdin < ./rystic-license.json
docker pull registry.rystic.ai/kalshi-twin:<version>
docker run --rm -e RYSTIC_LICENSE="$(base64 < ./rystic-license.json | tr -d '\n')" -p 8080:8080 \
registry.rystic.ai/kalshi-twin:<version>
<version>is a version your license covers —rystic listshows the latest, release notes list them all.- The username is anything; the password is the license, JSON or base64.
rystic login <file>does this for you. - Multi-arch (amd64, arm64); carries the simulator, its scenarios and
rystic; uid 10001; built-inHEALTHCHECK. - No license is in the image. It arrives at run time as
RYSTIC_LICENSE— never a layer, build secret or bind mount. Renewal is a new file, not a new image. - Tags are versions, never
latest— the version is the fidelity claim. Pin by digest for anything reproducible. - Only what your license covers resolves: another product answers
NAME_UNKNOWN, an uncut versionMANIFEST_UNKNOWN.
Through the CLI it is one flag:
rystic run kalshi-twin --runner docker -d
Compose
One file, one secret, no Dockerfile:
services:
kalshi-twin:
image: registry.rystic.ai/kalshi-twin@sha256:<digest> # or :<version>
environment:
RYSTIC_LICENSE: ${RYSTIC_LICENSE} # base64 of the license file
init: true
ports: ["8080:8080"]
bot:
image: your-bot:local
environment:
KALSHI_BASE_URL: http://kalshi-twin:8080/trade-api/v2
depends_on:
kalshi-twin: { condition: service_healthy }
RYSTIC_LICENSE=$(base64 < rystic-license.json | tr -d '\n') docker compose up
The bot starts only once the simulator serves. To replay a captured day, mount the library read-only (volumes: ["~/data/kalshi-orderbook:/tapes:ro"], KALSHI_CAPTURE_DIR: /tapes) — read the one outbound call first, because a read-only mount is exactly where the simulator cannot cache what it fetches. To feed a container the exchange as it moves instead, run rystic feed kalshi-twin --url http://localhost:8080 from any machine that reaches it — Live mode.
Generate the compose file
Name the simulators once and let the CLI pin the digests through the registry:
# rystic.yaml
version: 1
project: ci
services:
kalshi-twin: {product: kalshi-twin, version: <version>, port: 8080, scenario: thin_book}
rystic export compose -o ./deploy
cp deploy/.env.example deploy/.env && echo "RYSTIC_LICENSE=$(base64 < rystic-license.json | tr -d '\n')" >> deploy/.env
docker compose -f deploy/docker-compose.yaml up
rystic export antithesis -o ./anti --tenant <t> --gcp-project <p> writes the same compose with platform: linux/amd64, the FROM scratch config Dockerfile, and a README with the tenant push commands. Two notes: the simulator never emits setup_complete (your workload does), and copying the image into your own registry needs Rystic’s sign-off.
CI
Run the docker login inside the job with the license from your secret store. Under Antithesis the same compose file is the environment: the simulator is deterministic and, with the one exception below, makes no outbound calls.
For GitHub Actions specifically, see Integration tests in CI.
The one outbound call
A capture records a market’s book and its prints, not the market itself — and on a threshold market the strike is what the market means. So replaying a tape whose archive has no meta/ log makes the simulator fetch that market’s public record from the exchange: one unauthenticated GET, first play only, cached back into the archive. It is the only outbound call the simulator ever makes.
It is on by default, and yours to refuse:
docker run --rm -e RYSTIC_LICENSE="$(base64 < ./rystic-license.json | tr -d '\n')" \
-e RYSTIC_REPLAY_METADATA=off \
-p 8080:8080 registry.rystic.ai/kalshi-twin:<version>
RYSTIC_REPLAY_METADATA=off— no fetch. Replay is unaffected; strike fields are absent and the simulator logs which market it could not describe.RYSTIC_REPLAY_METADATA_HOSTS=<host>[,<host>]— keep the fetch, pin where it may go.- What leaving it on costs: replaying a tape tells the exchange which historical window you are studying.
- A failed fetch never fails a run — blocked egress degrades your markets quietly. Ship
meta/with your tapes, seed the record, or setoffso the loss is a choice.
Full account: Kalshi simulator.
Many simulators, many containers
A hand-picked set — the same stack file with runner: docker services; rystic up -d, rystic ps --project <p>, rystic down.
A backtesting fleet — put your bot’s runtime on our image and hand batch the result. Each world runs in its own container; ledger, state and logs land under -out exactly as native batch writes them.
FROM registry.rystic.ai/kalshi-twin:<version>
USER root
RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*
USER rystic
docker build -t kalshi-fleet:local .
rystic batch kalshi-twin --runner docker --image kalshi-fleet:local -tapes ~/data/kalshi-orderbook -j 4 -out ./fleet -- python3 my-bot.py
The license reaches each container only through its environment; workers never appear in rystic ps and are removed when the run ends. --image built from another product’s Rystic image is refused; an image with no Rystic lineage runs with a warning.
Behind a firewall
rystic run --runner docker builds the image locally from a pulled linux archive when the registry is unreachable — rystic pull -os linux -arch amd64 kalshi-twin fetches it, rystic config set image_source archive makes it the default.
Next
- Integration tests in CI — the simulator as a GitHub Actions service container.
- Kalshi simulator — seeding, replay, WebSocket feed.