# Order book and price grid

How a book reads back, how to seed one and the per-market price grid that decides which prices an order may carry.

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:

```bash
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:

```json
{"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:

```json
{"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_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:

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:

```python
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.

---

Source: https://www.rystic.ai/docs/simulators/kalshi/features/order-book · Markdown: https://www.rystic.ai/docs/simulators/kalshi/features/order-book.md
