Getting started
Integration tests in CI
Run the simulator as a GitHub Actions service container . The runner pulls the image, waits on its built-in HEALTHCHECK, and exposes the port to your test job. No compose file, no CLI in CI.
One secret
gh secret set RYSTIC_LICENSE --body "$(base64 < rystic-license.json | tr -d '\n')"
The registry accepts the license JSON or its base64 as the password; the runtime reads the same value.
The workflow
# .github/workflows/integration.yml
jobs:
integration:
runs-on: ubuntu-latest
services:
kalshi-twin:
image: registry.rystic.ai/kalshi-twin:0.0.25 # or pin by digest for repro
credentials:
username: license
password: ${{ secrets.RYSTIC_LICENSE }}
env:
RYSTIC_LICENSE: ${{ secrets.RYSTIC_LICENSE }}
ports: ["8080:8080"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- run: go test -tags integration ./...
env:
KALSHI_BASE_URL: http://localhost:8080/trade-api/v2
TWIN_URL: http://localhost:8080
- The job starts only once the
HEALTHCHECKreports healthy — no wait-for-port loop. - Tags are never
latest, so the workflow pins fidelity by construction. - No license is baked into the image; it arrives via
RYSTIC_LICENSEat run time.
The tests
One helper does the work — POST /_rystic/scenario wipes state then applies the named world, so tests are order-independent:
//go:build integration
var base = cmp.Or(os.Getenv("TWIN_URL"), "http://localhost:8080")
func scenario(t *testing.T, name string) {
t.Helper()
http.Post(base+"/_rystic/scenario", "application/json",
strings.NewReader(`{"name":"`+name+`"}`))
}
func TestBotSurvivesThinBook(t *testing.T) {
scenario(t, "thin_book")
runBot(t, 30*time.Second)
}
Check a trade went through, on the same surfaces live Kalshi has plus the simulator’s egress log:
func TestOrderFills(t *testing.T) {
scenario(t, "deep_book")
resp := post(t, base+"/trade-api/v2/portfolio/events/orders", `{
"ticker": "KXDEMO-26-A", "side": "bid", "price": "0.40", "count": "10",
"time_in_force": "fill_or_kill", "self_trade_prevention_type": "taker_at_cross",
"client_order_id": "11111111-1111-1111-1111-111111111111"
}`)
want(t, resp, `"fill_count":"10.00"`)
want(t, get(t, base+"/trade-api/v2/portfolio/fills"), `"is_taker":true`)
want(t, get(t, base+"/trade-api/v2/portfolio/positions"), `"position_fp":"10.00"`)
want(t, get(t, base+"/_rystic/egress?tail=50"), `"type":"fill"`)
}
- The first three are the real Kalshi API — the same assertions run unchanged against live.
/_rystic/egressis simulator-only: it proves the simulator emitted the fill frame the WebSocket feed streams from. It catches “state moved but the frame was never produced.”- One test per named world:
thin_book,flash_crash,rate_limited,flaky_500,exchange_halted,underwater— 45 in all (GET /_rystic/scenarios). - Determinism knobs:
POST /_rystic/seed-rngandPOST /_rystic/clock.
If your bot is an image
Use the compose route: rystic export compose locally, commit deploy/docker-compose.yaml, docker compose up in the job with the same secret. See Docker.
Last updated on