Simulators
Orders and matching
Orders your bot places cross the simulator’s book as they would cross Kalshi’s. Fills, position and balance changes and the WebSocket fill and market_position pushes all follow, identically on every run.
Here maker rests an ask and taker crosses it. The empty_book scenario seeds KXDEMO-26-A, a complete market record with no quotes. Balances are per identity, so fund both. All four fields are required: balance is cents and balance_dollars the same money as a decimal string.
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name trade --scenario empty_book --json | jq -r .data.url)
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_balance":{"maker":{"balance":1000000,"balance_dollars":"10000.0000","portfolio_value":0,"updated_ts":1784989800},"taker":{"balance":1000000,"balance_dollars":"10000.0000","portfolio_value":0,"updated_ts":1784989800}}}'
curl -s -X POST "$RYSTIC_URL/trade-api/v2/portfolio/events/orders" -H 'Content-Type: application/json' -H 'X-Rystic-Actor: maker' -d '{"ticker":"KXDEMO-26-A","client_order_id":"m1","side":"ask","count":"10","price":"0.40","time_in_force":"good_till_canceled","self_trade_prevention_type":"taker_at_cross"}' | jq -c '{order_id, fill_count, remaining_count}'
# -> {"order_id":"a7cade2e-8c1a-43f4-8000-000000000001","fill_count":"0.00","remaining_count":"10.00"}
curl -s "$RYSTIC_URL/trade-api/v2/markets/KXDEMO-26-A/orderbook"
# -> {"orderbook_fp":{"no_dollars":[["0.6000","10.00"]],"yes_dollars":[]}}
curl -s -X POST "$RYSTIC_URL/trade-api/v2/portfolio/events/orders" -H 'Content-Type: application/json' -H 'X-Rystic-Actor: taker' -d '{"ticker":"KXDEMO-26-A","client_order_id":"t1","side":"bid","count":"10","price":"0.40","time_in_force":"fill_or_kill","self_trade_prevention_type":"taker_at_cross"}' | jq -c '{fill_count, average_fill_price}'
# -> {"fill_count":"10.00","average_fill_price":"0.4000"}
curl -s "$RYSTIC_URL/trade-api/v2/portfolio/fills" -H 'X-Rystic-Actor: maker' | jq -c '.fills[] | {is_taker, count_fp, yes_price_dollars}'
# -> {"is_taker":false,"count_fp":"10.00","yes_price_dollars":"0.4000"}
curl -s "$RYSTIC_URL/trade-api/v2/portfolio/positions" -H 'X-Rystic-Actor: maker' | jq -c '.market_positions[] | {ticker, position_fp}'
# -> {"ticker":"KXDEMO-26-A","position_fp":"-10.00"}
Read each side back with the header you traded under: a plain curl reads member’s account, which holds no order. maker is short 10 because it sold 10 YES.
The V2 order routes
Trade through /portfolio/events/orders, in the book-side convention {"side": "bid"|"ask", "price", "count"}. Creating an order requires time_in_force: fill_or_kill, good_till_canceled or immediate_or_cancel. Creating, amending, decreasing, canceling and batching orders all act on the simulator’s own matching book. That’s the book GET /markets/{ticker}/orderbook returns, and no order reaches Kalshi. Quotes in the RFQ desk are addressable both flat (/communications/quotes/{id}) and nested under their RFQ.
- Partial fills are normal. A marketable limit sweeps whatever depth is on the book and the remainder rests, with counts in 2-decimal fixed point. The
fill_countandremaining_countin the response to creating an order are the signal, not an error code. - The cancel-all kill switch returns 404.
DELETE /portfolio/events/ordersreturns404, as measured on Kalshi’s demo exchange, and leaves your orders resting. Cancel by id or withDELETE /portfolio/events/orders/batched. GET /portfolio/orderspages like Kalshi’s. It serves 100 orders by default (?limit=up to 1000) and returns acursorwhile more remain, so follow it until it comes back empty. A reconciler that treats page one as the whole book breaks once it holds more than a page of orders: it decides the rest were canceled. The live API does the same to that bot.GET /marketspages the same way, newestcreated_timefirst.- The taker’s fee follows the fee schedule:
ceil(0.07 × fee_multiplier × count × price × (1 − price))per fill, at the micro-dollar.fee_multiplieris the series’ own:1on every production series,KXBTC15Mincluded. Fees are per series, andKXDEMOdeclares none. - Another actor’s
order_idreturns 404 rather than leaking.
The retired V1 order writes
Since June 2026 the live exchange returns 410 to every V1 order write. The simulator returns the same bytes, taken from captures of Kalshi’s demo exchange, on each of them:
POST /portfolio/ordersPOST /portfolio/orders/{order_id}/amendDELETE /portfolio/orders/{order_id}DELETE /portfolio/orders/batched
The body is {"error":{"code":"deprecated_v1_order_endpoint","message":"Please switch to the V2 endpoints",…}}, so a bot built here fails the way it would in production.
A V1-era client you can’t change yet keeps its outcome-side wire ({"side": "yes"|"no", "action", "count_fp", "yes_price_dollars"|"no_price_dollars"}) when the simulator starts with RYSTIC_KALSHI_LEGACY_ORDERS=on.
Differs from Kalshi: with RYSTIC_KALSHI_LEGACY_ORDERS=on, the V1 order writes work, where Kalshi returns 410 on those paths. The setting is a hosting convenience, not a claim about the live exchange.
Matching, queue position and limits
Matching on a hand-seeded or scenario book follows price-time priority. A replayed tape adds recorded flow, and what replay does not model says what it doesn’t model. Live two-account tests on Kalshi’s demo exchange confirmed these matching rules:
- price-time priority
- amending an order loses its place in the queue, and decreasing one keeps it
- a resting order holds no buying power
- a batch can be accepted in part
- the taker fee math
The queue position of a resting order is at GET /portfolio/orders/{order_id}/queue_position. One process holds one exchange: run several simulators on different ports for isolated books.