Simulators
Order book and price grid
The book you read and the book you seed are two different shapes, and each market declares its own price grid. Mixing either up is the most common first-hour mistake, so this page spells out both from the bytes the simulator serves.
The empty_book scenario holds one market, KXDEMO-26-A, with no quotes. Seed a book on it and read it back:
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name book --scenario empty_book --json | jq -r .data.url)
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_orderbook":{"KXDEMO-26-A":{"ticker":"KXDEMO-26-A","bids":[{"price_dollars":"0.40","resting":[{"order_id":"seed-b1","remaining_count_fp":"150.00","seq":1}]}],"asks":[{"price_dollars":"0.45","resting":[{"order_id":"seed-a1","remaining_count_fp":"200.00","seq":3}]}]}}}'
curl -s "$RYSTIC_URL/trade-api/v2/markets/KXDEMO-26-A/orderbook"
# -> {"orderbook_fp":{"no_dollars":[["0.5500","200.00"]],"yes_dollars":[["0.4000","150.00"]]}}
curl -s "$RYSTIC_URL/trade-api/v2/markets/KXDEMO-26-A" | jq -c '.market | {price_level_structure, price_ranges}'
# -> {"price_level_structure":"linear_cent","price_ranges":[{"end":"1.0000","start":"0.0000","step":"0.0100"}]}
curl -s -X POST "$RYSTIC_URL/trade-api/v2/portfolio/events/orders" -H 'Content-Type: application/json' -d '{"ticker":"KXDEMO-26-A","client_order_id":"g1","side":"bid","count":"1","price":"0.4001","time_in_force":"good_till_canceled","self_trade_prevention_type":"taker_at_cross"}' | jq -c .error.code
# -> "invalid_price"
Reading a book
GET /trade-api/v2/markets/{ticker}/orderbook returns:
{"orderbook_fp": {
"yes_dollars": [["0.3900", "75.00"], ["0.4000", "150.00"]],
"no_dollars": [["0.5500", "200.00"]]}}
- Everything hangs off
orderbook_fp, and each side is a list of[price, count]string pairs, not objects. The response has nobidsoraskskeys. yes_dollarsis the bid stack.no_dollarsis the ask stack re-expressed as NO bids at1.00 - yes_price: a resting YES ask at0.4500appears as0.5500onno_dollars. That is the live exchange’s dual, not an invention of the simulator.- Levels ascend by price, so the best one is last. Top of book is
yes_dollars[-1], never[0]. - Prices are 4 decimal places and counts 2. A market with no resting orders returns
[]and[]. So does an unknown ticker, because this endpoint never returns 404, and Kalshi’s doesn’t either.
Seeding a book
Seed the register state_orderbook, keyed by ticker:
{"state_orderbook": {"KXDEMO-26-A": {
"ticker": "KXDEMO-26-A",
"bids": [{"price_dollars": "0.40",
"resting": [{"order_id": "seed-b1", "remaining_count_fp": "150.00", "seq": 1}]}],
"asks": [{"price_dollars": "0.45",
"resting": [{"order_id": "seed-a1", "remaining_count_fp": "200.00", "seq": 3}]}]}}}
- The keys are
bidsandasks, each level priced inprice_dollars. The read side’syes_dollars,no_dollarsand[price, count]pairs don’t work here. - Both sides are on the YES price axis: an entry in
asksis a YES ask at its own price. Do not convert it to a NO price, because the read side does that for you (the0.45ask above reads back as0.5500). - A level is an object carrying
price_dollarsand arestingarray. Each resting entry needsremaining_count_fpand aseq, the price-time priority tiebreak: lower is older, so it fills first. - A level with an empty or missing
restingarray is silently dropped. The seed still returns204, and the book reads back as empty. If you seeded a book and got an empty one, this is almost always why.
Seeding the register is the precise way to build a book. For most tests a scenario, a boot file or simply placing orders is easier, and all of them land in the same matching engine.
The price grid
Every market declares which prices it accepts, and they are not the same across markets:
price_level_structure | What it accepts |
|---|---|
tapered_deci_cent | tenths of a cent below $0.10 and above $0.90, and whole cents in between |
linear_cent | whole cents only |
deci_cent | tenths of a cent everywhere |
Don’t memorize which series is which. A grid belongs to a market and it moves: Kalshi migrated KXGOLD15M from linear_cent to tapered_deci_cent partway through 2026-08-20, so two markets of one series on one day carry different grids. Three rules have bitten real bots:
price_rangesis the authoritative tick source. Read it before you price anything.price_level_structureis an advisory label, so never infer a tick from it.- Read it per market, and read it again. Not per series and not once per session: it is stable for a given market, and nothing else about it is.
- Read every band, not
price_ranges[0]. A tapered market declares three. A bot that applies the first band’s0.0010step to a mid in the whole-cent middle quotes0.4990and gets400 invalid_priceon every order. This is the most common way a fixed-point client fails against a real Kalshi market.
Nothing about a ticker tells you its tick, so only the market’s own price_ranges decides. A price is placeable if it is strictly inside (0, $1) and on-step within some declared band. The simulator enforces exactly that and returns 400 invalid_price otherwise, as the exchange does:
def on_grid(price_cc, ranges): # price in centi-cents (dollars * 1e4)
if not 0 < price_cc < 10000:
return False
return any(cc(r["start"]) <= price_cc <= cc(r["end"])
and (price_cc - cc(r["start"])) % cc(r["step"]) == 0
for r in ranges)
The bundled simple-bot.py and random-walker.py both check the grid this way, and you can copy from them.