Skip to Content
Docker

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>
  1. <version> is a version your license covers — rystic list shows the latest, release notes list them all.
  2. The username is anything; the password is the license, JSON or base64. rystic login <file> does this for you.
  3. Multi-arch (amd64, arm64); carries the simulator, its scenarios and rystic; uid 10001; built-in HEALTHCHECK.
  4. 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.
  5. Tags are versions, never latest — the version is the fidelity claim. Pin by digest for anything reproducible.
  6. Only what your license covers resolves: another product answers NAME_UNKNOWN, an uncut version MANIFEST_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>
  1. RYSTIC_REPLAY_METADATA=off — no fetch. Replay is unaffected; strike fields are absent and the simulator logs which market it could not describe.
  2. RYSTIC_REPLAY_METADATA_HOSTS=<host>[,<host>] — keep the fetch, pin where it may go.
  3. What leaving it on costs: replaying a tape tells the exchange which historical window you are studying.
  4. A failed fetch never fails a run — blocked egress degrades your markets quietly. Ship meta/ with your tapes, seed the record, or set off so 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

  1. Integration tests in CI — the simulator as a GitHub Actions service container.
  2. Kalshi simulator — seeding, replay, WebSocket feed.
Last updated on