Skip to Content
SimulatorsKalshiOrder book and price grid

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 no bids or asks keys.
  • yes_dollars is the bid stack. no_dollars is the ask stack re-expressed as NO bids at 1.00 - yes_price: a resting YES ask at 0.4500 appears as 0.5500 on no_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 bids and asks, each level priced in price_dollars. The read side’s yes_dollars, no_dollars and [price, count] pairs don’t work here.
  • Both sides are on the YES price axis: an entry in asks is a YES ask at its own price. Do not convert it to a NO price, because the read side does that for you (the 0.45 ask above reads back as 0.5500).
  • A level is an object carrying price_dollars and a resting array. Each resting entry needs remaining_count_fp and a seq, the price-time priority tiebreak: lower is older, so it fills first.
  • A level with an empty or missing resting array is silently dropped. The seed still returns 204, 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_structureWhat it accepts
tapered_deci_centtenths of a cent below $0.10 and above $0.90, and whole cents in between
linear_centwhole cents only
deci_centtenths 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:

  1. price_ranges is the authoritative tick source. Read it before you price anything. price_level_structure is an advisory label, so never infer a tick from it.
  2. 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.
  3. Read every band, not price_ranges[0]. A tapered market declares three. A bot that applies the first band’s 0.0010 step to a mid in the whole-cent middle quotes 0.4990 and gets 400 invalid_price on 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.

Last updated on