hftbacktest is an open-source backtester built for order books: it replays every book change and trade, keeps your place in the queue, and delays your orders by a latency you set. That is what decides whether a resting bid fills. This guide converts Kalshi Eye's book_l2 and trades files into hftbacktest events, then tests a bid that waits in the queue against buying at the ask.
Updated November 13, 2026. Run as shown on the free sample with hftbacktest 2.4.4 and Python 3.12.
What you need
pip install "hftbacktest==2.4.4" "kalshieye[pandas]" duckdb
hftbacktest 2.4.4 needs numba and a numpy below 2.3; pip picks matching versions. The data is the free sample, BTC 15 min (KXBTC15M) on October 25, 2026, which any account downloads in full. Other days of book_l2 are on Full book.
from kalshieye import Client Client().download(["book_l2", "trades", "settlements"], "2026-10-25", "2026-10-25", series=["KXBTC15M"])
Without an account, the sample on Hugging Face has the same files. The blocks below are one script; run it from the folder that holds kalshieye-data.
1. Setup
Four markets in a row, closing at noon, 12:15, 12:30 and 12:45 PM Eastern (the time in each ticker). Every event carries both the exchange and the local flag, so the simulated exchange and your strategy both see it.
import math
import duckdb
import numpy as np
from numba import njit
from hftbacktest import (
BUY_EVENT, DEPTH_CLEAR_EVENT, DEPTH_EVENT, EXCH_EVENT, GTX, LIMIT, LOCAL_EVENT, SELL_EVENT, TRADE_EVENT,
BacktestAsset, HashMapMarketDepthBacktest,
)
from hftbacktest.order import IOC
from hftbacktest.types import event_dtype
DATA = "kalshieye-data"
SERIES = "KXBTC15M"
TICKERS = [
"KXBTC15M-26OCT251200-00",
"KXBTC15M-26OCT251215-15",
"KXBTC15M-26OCT251230-30",
"KXBTC15M-26OCT251245-45",
]
F = EXCH_EVENT | LOCAL_EVENT # every event reaches both the exchange and your strategy
con = duckdb.connect()2. Convert the files to events
hftbacktest reads one array of events: depth changes with the new size at a price, trades, and clears. One query builds it. A snapshot clears every bid and every ask, then sets its levels. Delta rows in book_l2 are signed changes, so a running sum per level since the last snapshot gives the size. A NO bid at p is a YES ask at 1 − p. A trade whose taker bought YES is a buy; a NO taker sold YES.
def events(ticker: str) -> np.ndarray:
"""One market's book_l2 and trades as hftbacktest events, in YES terms."""
rows = con.sql(f"""
WITH book AS (
SELECT epoch_ns(ts_recv) AS ts, seq, is_snapshot, side, price, size
FROM read_parquet('{DATA}/book_l2/date=*/series={SERIES}/*.parquet')
WHERE ticker = '{ticker}'
),
marked AS ( -- the first row of each snapshot
SELECT *, is_snapshot AND ts IS DISTINCT FROM lag(ts) OVER (ORDER BY ts, seq) AS starts
FROM book
),
levels AS ( -- deltas are signed changes: a running sum since the last snapshot is the size
SELECT *, greatest(round(sum(size) OVER (
PARTITION BY snap, side, price ORDER BY ts, seq ROWS UNBOUNDED PRECEDING), 2), 0) AS qty
FROM (SELECT *, sum(starts::INT) OVER (ORDER BY ts, seq ROWS UNBOUNDED PRECEDING) AS snap FROM marked)
)
SELECT ts, ev, px, qty FROM (
-- A snapshot clears every bid (down to 0) and every ask (up to 1) first.
SELECT ts, seq, 0 AS k, {DEPTH_CLEAR_EVENT | BUY_EVENT | F} AS ev, 0.0 AS px, 0.0 AS qty FROM marked WHERE starts
UNION ALL
SELECT ts, seq, 0, {DEPTH_CLEAR_EVENT | SELL_EVENT | F}, 1.0, 0.0 FROM marked WHERE starts
UNION ALL
-- YES bids are bids. A NO bid at p is a YES ask at 1 - p.
SELECT ts, seq, 1,
CASE side WHEN 'yes' THEN {DEPTH_EVENT | BUY_EVENT | F} ELSE {DEPTH_EVENT | SELL_EVENT | F} END,
CASE side WHEN 'yes' THEN price ELSE round(1 - price, 2) END, qty
FROM levels WHERE side IS NOT NULL
UNION ALL
-- A YES taker bought; a NO taker sold YES.
SELECT epoch_ns(ts_recv), 0, 2,
CASE taker_side WHEN 'yes' THEN {TRADE_EVENT | BUY_EVENT | F} ELSE {TRADE_EVENT | SELL_EVENT | F} END,
yes_price, size
FROM read_parquet('{DATA}/trades/date=*/series={SERIES}/*.parquet')
WHERE ticker = '{ticker}'
)
ORDER BY ts, k, seq
""").fetchnumpy()
out = np.zeros(len(rows["ts"]), event_dtype)
out["ev"], out["px"], out["qty"] = rows["ev"], rows["px"], rows["qty"]
# Only the recorder's receive time is known: it stands for both clocks. Order latency is set below.
out["exch_ts"] = out["local_ts"] = rows["ts"]
return outThe files record when the recorder received each message, which stands for both of hftbacktest's clocks. The latency of your own orders is set on the asset below.
3. The strategy
Five minutes before the close it either bids at the best YES bid with a post-only order (GTX), which waits in the queue, or buys at the ask with IOC. A bid still unfilled a minute before the close is canceled. The loop is compiled with numba, as hftbacktest requires.
@njit
def buy_yes(hbt, entry_ts, cancel_ts, contracts, passive):
"""At entry_ts, bid at the best YES bid and wait in the queue (passive), or buy at the ask.
A passive bid not filled by cancel_ts is canceled. Returns (contracts held, price)."""
price = np.nan
while hbt.elapse(100_000_000) == 0: # step 100 ms at a time
now = hbt.current_timestamp
if np.isnan(price) and now >= entry_ts:
depth = hbt.depth(0)
price = depth.best_bid if passive else depth.best_ask
hbt.submit_buy_order(0, 1, price, contracts, GTX if passive else IOC, LIMIT, False)
if not np.isnan(price) and now >= cancel_ts:
if hbt.position(0) < contracts:
hbt.cancel(0, 1, False) # cancel only what is still open
break
hbt.elapse(1_000_000_000) # let the cancel or a last fill arrive
return hbt.position(0), price4. Run it and settle
Each market runs on its own. linear_asset(1.0) makes a contract worth its price in dollars, the queue model is the cautious one, and orders take 50 ms each way. hftbacktest doesn't settle contracts, so the payout is added after: what is held times the settlement value (1 or 0), less the price, less Kalshi's fee for the taker.
settled = con.sql(f"""
SELECT ticker, settlement_value, epoch_ns(close_ts) AS close_ns
FROM read_parquet('{DATA}/settlements/date=*/*.parquet')
WHERE series = '{SERIES}' AND result IN ('yes', 'no')
""").df().set_index("ticker")
for passive in (True, False):
total = 0.0
for t in TICKERS:
close_ns, value = int(settled.loc[t, "close_ns"]), float(settled.loc[t, "settlement_value"])
data = events(t) # keep a reference: the backtest reads this array in place
asset = (
BacktestAsset()
.data([data])
.linear_asset(1.0) # a contract pays $1 at a price of 1
.constant_order_latency(50_000_000, 50_000_000) # 50 ms to the exchange and back
.risk_adverse_queue_model() # a bid fills only after the size ahead of it trades
.no_partial_fill_exchange() # fill a whole order or none of it
.trading_value_fee_model(0.0, 0.0) # Kalshi's fee is charged below
.tick_size(0.01)
.lot_size(0.01)
)
hbt = HashMapMarketDepthBacktest([asset])
held, price = buy_yes(hbt, close_ns - 300 * 10**9, close_ns - 60 * 10**9, 10.0, passive)
hbt.close()
# KXBTC15M charges takers only: 0.07 x P x (1 - P) a contract, rounded up to the cent.
fee = 0.0 if passive else math.ceil(round(0.07 * held * price * (1 - price) * 100, 6)) / 100
pnl = held * (value - price) - fee
total += pnl
print(f"{'bid' if passive else 'ask'} {t} held {held:5.2f} at {price:.2f} settled {value:g} fee {fee:.2f} pnl {pnl:+.2f}")
print(f"{'bid' if passive else 'ask'} total {total:+.2f}\n")
con.close()It prints:
bid KXBTC15M-26OCT251200-00 held 10.00 at 0.21 settled 0 fee 0.00 pnl -2.10 bid KXBTC15M-26OCT251215-15 held 10.00 at 0.20 settled 0 fee 0.00 pnl -2.00 bid KXBTC15M-26OCT251230-30 held 10.00 at 0.04 settled 0 fee 0.00 pnl -0.40 bid KXBTC15M-26OCT251245-45 held 10.00 at 0.35 settled 1 fee 0.00 pnl +6.50 bid total +2.00 ask KXBTC15M-26OCT251200-00 held 10.00 at 0.22 settled 0 fee 0.13 pnl -2.33 ask KXBTC15M-26OCT251215-15 held 10.00 at 0.21 settled 0 fee 0.12 pnl -2.22 ask KXBTC15M-26OCT251230-30 held 0.00 at 0.05 settled 0 fee 0.00 pnl -0.00 ask KXBTC15M-26OCT251245-45 held 10.00 at 0.36 settled 1 fee 0.17 pnl +6.23 ask total +1.68
On these four markets the bid filled every time, a cent under the ask and with no fee, so it made $2.00 against $1.68. On the 12:30 PM market the few contracts at the 5¢ ask were gone by the time the 50 ms order arrived, so the taker bought nothing. The ask side matches NautilusTrader's fills and fees on the other three. Four markets prove the code, not the rule: a bid fills most often when the price is about to move against it, which only shows over many days.
Things to know
- Keep a reference to the array. hftbacktest reads the event array in place. Passing
events(t)straight into.data([...])lets Python free it and crashes the run. - Cancel only open orders. In our runs with 2.4.4, canceling an order that had already filled filled it a second time, so the loop checks the position first.
- Whole fills. In our runs,
partial_fill_exchangesometimes filled a resting bid past its size on fractional Kalshi sizes, so this usesno_partial_fill_exchange. - Fees. hftbacktest's fee models are a share of value or a fixed amount per contract. Kalshi's fee depends on price as P × (1 − P), so it is charged after the run.
- Gaps. Leave out hours listed in incidents.
Questions
Why hftbacktest for prediction markets?
It models what decides whether a resting order fills: how much size was ahead of it in the queue and how long orders take to arrive. Those matter most on short markets, where a cent of spread and a fee are most of the edge.
Do resting orders pay Kalshi's fee?
On most series, no: KXBTC15M's fee_type is quadratic, which charges takers only. Series marked quadratic_with_maker_fees charge resting orders too. The fee_type is in the series and markets files.
Which queue model should I use?
Start with risk_adverse_queue_model: your order moves up only when trades at your price take the size ahead of it, never when others cancel. It is the most cautious. The probability models assume some cancels come from ahead of you and fill sooner.
Next
- NautilusTrader: the same markets in an event-driven engine that settles positions itself.
- Backtest with an AI agent: have a coding agent run and check this.
- Kalshi order book data: how book_l2 works.