Skip to content
Kalshi Eye

How to backtest Kalshi markets

A backtest asks one question: if I had traded this rule in the past, at prices I could really have traded at, what would I have made? On Kalshi that takes three things: how every market settled, the price on the order book when the rule fired, and Kalshi's fee. This guide builds one in DuckDB from the archive's daily Parquet files, starting with what the free plan includes.

Updated October 24, 2026. Every query was run on the free sample files.

What you need

  • Python and DuckDB: pip install duckdb. Any tool that reads Parquet works; DuckDB keeps the examples to plain SQL.
  • Outcomes: settlements, every market that settled each day with its result and payout.
  • Prices: bbo_1m, minute bars with the closing bid and ask. For prices at the moment you trade, bbo (every top-of-book change), book_snap_1s (top ten levels every second) or book_l2 (every level, every change).
  • Gaps: incidents, every hour the recorder was not connected or missed messages.

Settlements, bbo_1m and incidents are on the free Explorer plan (last 30 days); bbo and book_snap_1s on Research; book_l2 on Full book. Download them from Downloads, with the Python client, or by S3 sync (Bulk downloads). The queries assume the layout the downloads keep, kalshieye-data/<dataset>/date=<day>/…, and run from the folder that holds kalshieye-data.

To try them without an account, download the free one-day sample on Hugging Face: October 13, 2026 for seven series, every dataset, in this same layout. The examples on GitHub run this backtest on it in place, without downloading anything.

1. Get the outcomes

Each settlements file lists the markets that settled that UTC day. result is yes, no or void, and settlement_value is what one YES contract paid in dollars: 1 or 0 for a binary market. Leave void markets out (positions were refunded), and read one day past your test window: a market that closes late in the day can settle after midnight UTC.

SET TimeZone = 'UTC';

SELECT ticker, result, settlement_value, close_ts, settlement_ts
FROM read_parquet('kalshieye-data/settlements/date=*/*.parquet')
WHERE series = 'KXBTC15M' AND result IN ('yes', 'no');

2. Take the price you could have traded

A buy fills at the ask and a sell at the bid. The mid and the last trade are prices nobody offered you, and on a market with a 4-cent spread they overstate every trade by about 2 cents. Kalshi's book is bids only: a NO bid at 40¢ is an offer to sell YES at 60¢. The files already show that as yes_ask, and a null ask means nobody was selling.

Mind when a value became known. A bbo_1m row is labeled with the minute it starts, but its closing bid and ask are only known when the minute ends. Using a bar at its start is lookahead: it reads a price from the future.

3. Charge the fee

Kalshi's taker fee per contract is fee_multiplier × 0.07 × P × (1 − P), rounded up to the cent per order. At 50¢ that is 1.75¢ a contract, at 90¢ 0.63¢. Rounding is per order, so a one-contract order at 50¢ pays 2¢. The multiplier is 1 for most series; it is in the markets and series files with fee_type, which says whether resting (maker) orders pay too.

4. Put it together

The rule below buys one YES contract in every 15-minute bitcoin market (KXBTC15M) at the ask five minutes before close, skips hours with recorder gaps, and groups the results by entry price. If the market were perfectly calibrated, yes_rate would match avg_ask in every bucket and pnl_per_contract would be minus the fee.

SET TimeZone = 'UTC';

-- ::TIMESTAMPTZ lets every DuckDB version do interval math on the nanosecond timestamps.
WITH settled AS (
  SELECT ticker, settlement_value, close_ts::TIMESTAMPTZ AS close_ts
  FROM read_parquet('kalshieye-data/settlements/date=*/*.parquet')
  WHERE series = 'KXBTC15M' AND result IN ('yes', 'no')
),
bars AS (
  SELECT ticker, minute::TIMESTAMPTZ AS minute, yes_ask_close
  FROM read_parquet('kalshieye-data/bbo_1m/date=*/series=KXBTC15M/*.parquet')
),
gaps AS (
  -- Hours the recorder did not see in full.
  SELECT hour_start::TIMESTAMPTZ AS hour_start
  FROM read_parquet('kalshieye-data/incidents/date=*/*.parquet')
  WHERE coverage_pct < 100
),
entries AS (
  -- Each market's last bar that had ended 5 minutes before close. Nothing later is read.
  SELECT b.ticker, b.minute, b.yes_ask_close AS ask, s.settlement_value
  FROM bars b JOIN settled s USING (ticker)
  WHERE b.minute + INTERVAL 1 MINUTE <= s.close_ts - INTERVAL 5 MINUTE
  QUALIFY row_number() OVER (PARTITION BY b.ticker ORDER BY b.minute DESC) = 1
)
SELECT
  floor(ask * 10) / 10 AS bucket,
  count(*) AS markets,
  round(avg(ask), 3) AS avg_ask,
  round(avg(settlement_value), 3) AS yes_rate,
  round(avg(settlement_value - ask - 0.07 * ask * (1 - ask)), 4) AS pnl_per_contract
FROM entries
WHERE ask IS NOT NULL                                  -- an empty ask: nothing to buy
  AND date_trunc('hour', minute) NOT IN (SELECT hour_start FROM gaps)
GROUP BY bucket
ORDER BY bucket;

On a single day of 15-minute markets it returns ten buckets from fewer than a hundred markets. That is far too few to mean anything: run it over every day you have, and split the days into one period to find a rule and a later one to test it.

5. Read the book at the moment you trade

Minute bars are coarse: the ask at the end of a minute is not the ask at the second your signal fired, and they say nothing about size. With bbo, an ASOF join takes the quote standing at the entry time (the last change received at or before it) with the contracts offered at that price.

SET TimeZone = 'UTC';

WITH entries AS (
  SELECT ticker, settlement_value,
         close_ts::TIMESTAMPTZ - INTERVAL 5 MINUTE AS entry_ts
  FROM read_parquet('kalshieye-data/settlements/date=*/*.parquet')
  WHERE series = 'KXBTC15M' AND result IN ('yes', 'no')
),
quotes AS (
  SELECT ticker, ts_recv::TIMESTAMPTZ AS ts_recv, yes_ask, yes_ask_size
  FROM read_parquet('kalshieye-data/bbo/date=*/series=KXBTC15M/*.parquet')
)
SELECT e.ticker, e.entry_ts,
       e.entry_ts - q.ts_recv AS quote_age,
       q.yes_ask,
       least(100, q.yes_ask_size) AS filled,           -- a 100-contract order takes what was there
       e.settlement_value
FROM entries e ASOF JOIN quotes q
  ON e.ticker = q.ticker AND e.entry_ts >= q.ts_recv  -- the last quote received at or before entry
WHERE q.yes_ask IS NOT NULL;

quote_age says how long that quote had been standing. A long age means the top of the book had not changed or the recorder was not connected; the incidents file tells you which. If your order is bigger than yes_ask_size, the rest fills at worse prices: walk the levels in book_snap_1s, or replay book_l2 with replay_book from the Python client to see every level at any instant. Add your own latency to entry_ts too: the book you hit is the one standing when your order arrives.

Mistakes that make backtests look better than trading

  • Trading at the mid or the last trade. Buy at the ask, sell at the bid, and only up to the size offered.
  • Lookahead. Using a bar before it ends, ordering by ts_venue when you could only act on ts_recv, or reading result from a markets file: the file for a day lists results for markets settled that day, so it knows the ending.
  • Survivorship. Building the universe from markets that traded or settled. The markets file lists every market open at any time that day, including ones nobody traded; start from it.
  • Gaps. A quote carried through an hour the recorder missed looks like a tradable price. Drop hours listed in incidents, or the markets alive during them. The Data coverage page shows the same record hour by hour.
  • Fees and rounding. At mid prices the fee is a large share of a small edge, and rounding up per order punishes small orders most.
  • Early close. Markets with can_close_early can stop trading before the scheduled time. Use the settlement's close_ts, not the schedule, to place entries.
  • Too little history. Order book recording started on October 12, 2026. Short-dated series such as 15-minute and hourly markets give hundreds of markets a week; monthly ones need far longer before a result is more than noise.

Questions

Does Kalshi provide historical order book data?

Kalshi's API returns the order book as it is now, and past trades and candlesticks through its historical endpoints. It has no endpoint for past order books, so testing against the bid and ask you would have hit needs a recording of the book as it changed.

Can I backtest Kalshi markets for free?

Yes. The free Explorer plan downloads the last 30 days of 1-minute bars, trades, settlements, market lists and recorder incidents, which is everything the minute-bar backtest in this guide reads. Sample files of every dataset download without an account.

Should I order rows by ts_recv or ts_venue?

By ts_recv, the time the recorder received each message. It is the time you could have acted on the data, and it is always present; ts_venue is the time Kalshi stamped on the message.

How much history is there?

Order books, trades and settlements are recorded from October 12, 2026, every day since, each day published the next morning.

Next