backtesting.py is the smallest of the popular open-source backtesters: one table of bars, a strategy class with next(), and run(). It fits rules that trade on one-minute bars. This guide runs one on every 15-minute bitcoin market of a day, from Kalshi Eye's bbo_1m bars, with Kalshi's fee and a payout of 0 or 1 at the close.
Updated November 13, 2026. Run as shown on the free sample with backtesting 0.6.6 and Python 3.12.
What you need
pip install "backtesting==0.6.6" "kalshieye[pandas]" duckdb
The data is the free sample, BTC 15 min (KXBTC15M) on October 25, 2026, which any account downloads. Minute bars on other days are on Research.
from kalshieye import Client Client().download(["bbo_1m", "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. Load the bars and the outcomes
A bbo_1m row is labeled with the minute it starts, but its closing ask is known only when that minute ends, so each bar is placed at its end. Using it at its start would read a price a minute early.
import math
import warnings
import duckdb
import pandas as pd
from backtesting import Backtest, Strategy
warnings.filterwarnings("ignore", category=UserWarning, module="backtesting")
DATA = "kalshieye-data"
SERIES = "KXBTC15M"
CONTRACTS = 10
con = duckdb.connect()
bars = con.sql(f"""
SELECT ticker,
minute::TIMESTAMPTZ + INTERVAL 1 MINUTE AS bar_end, -- a bar's closing ask is known when the minute ends
yes_ask_close AS ask
FROM read_parquet('{DATA}/bbo_1m/date=*/series={SERIES}/*.parquet')
WHERE yes_ask_close IS NOT NULL -- no ask: nothing to buy that minute
""").df()
settled = con.sql(f"""
SELECT ticker, settlement_value, close_ts::TIMESTAMPTZ AS close_ts
FROM read_parquet('{DATA}/settlements/date=*/*.parquet')
WHERE series = '{SERIES}' AND result IN ('yes', 'no')
""").df()
con.close()2. Kalshi's fee and the rule
backtesting.py takes a commission function of the order size and price, which fits Kalshi's taker fee exactly: 0.07 × P × (1 − P) a contract, rounded up to the cent. The rule buys 10 YES contracts on the last bar that ended five minutes or more before the close.
def kalshi_fee(size, price):
"""Kalshi's taker fee: 0.07 x P x (1 - P) a contract, rounded up to the cent. It is 0 at 0 and 1,
so the settlement bar costs nothing."""
return math.ceil(round(0.07 * abs(size) * price * (1 - price) * 100, 6)) / 100
class BuyBeforeClose(Strategy):
entry_time = None # set per market
def init(self):
pass
def next(self):
if self.data.index[-1] == self.entry_time and not self.position:
self.buy(size=CONTRACTS)3. Run every market
Every price in the bars is the ask, since the rule only buys. The settlement value goes in as two last bars, so the position left open closes at what a YES contract paid. Two, because an order fills at the close of the bar before the one that handles it.
rows = []
for s in settled.itertuples():
b = bars[bars["ticker"] == s.ticker].set_index("bar_end").sort_index()
b = b[b.index <= s.close_ts - pd.Timedelta(minutes=5)]
if b.empty:
continue
# Every price is the ask: this rule only buys. Then the settlement value, what a YES contract paid,
# as two bars: an order fills at the close of the bar before the one that processes it, so the
# trade left open at the end closes at the first of them.
ohlc = pd.DataFrame({c: b["ask"] for c in ["Open", "High", "Low", "Close"]})
ohlc.loc[s.close_ts] = [s.settlement_value] * 4
ohlc.loc[s.close_ts + pd.Timedelta(seconds=1)] = [s.settlement_value] * 4
bt = Backtest(ohlc, BuyBeforeClose, cash=1_000, commission=kalshi_fee,
trade_on_close=True, finalize_trades=True)
stats = bt.run(entry_time=b.index[-1])
rows.append({"ticker": s.ticker, "ask": b["ask"].iloc[-1], "won": s.settlement_value,
"pnl": stats["Equity Final [$]"] - 1_000, "trades": stats["# Trades"]})
res = pd.DataFrame(rows)
res["bucket"] = (res["ask"] * 10).apply(math.floor) / 10
print(res.groupby("bucket").agg(markets=("ticker", "size"), avg_ask=("ask", "mean"),
yes_rate=("won", "mean"), pnl=("pnl", "sum")).round(3).to_string())
print(f"{len(res)} markets, {res['trades'].sum()} trades, total pnl {res['pnl'].sum():+.2f} on {CONTRACTS} contracts each")It prints progress bars, and a divide-by-zero warning from the statistics when a price is 0; both are harmless. The result:
markets avg_ask yes_rate pnl bucket 0.0 22 0.043 0.000 -10.13 0.1 7 0.163 0.286 7.92 0.2 9 0.239 0.000 -22.71 0.3 7 0.327 0.286 -4.01 0.4 5 0.468 0.800 15.70 0.5 7 0.549 0.143 -29.66 0.7 9 0.742 0.667 -8.05 0.8 11 0.860 0.909 4.44 0.9 18 0.966 1.000 5.65 95 markets, 95 trades, total pnl -40.85 on 10 contracts each
95 of the day's 101 settled markets had a bar to enter on; the other six opened on October 24, whose files are not in the sample. Markets bought at 90¢ and up all paid out but made $5.65 between 18 of them, and the day lost $40.85 on 950 contracts. One day is far too little to judge a rule; run it over every day you have, and find the rule on one stretch and test it on a later one.
4. Check it another way
The same rule in DuckDB gives the same total to the cent. A second method like this is the quickest way to catch a backtest that reads the wrong bar or skips a fee.
SET TimeZone = 'UTC';
WITH s AS (
SELECT ticker, settlement_value AS v, close_ts::TIMESTAMPTZ AS close_ts
FROM read_parquet('kalshieye-data/settlements/date=*/*.parquet')
WHERE series = 'KXBTC15M' AND result IN ('yes', 'no')
),
b AS (
SELECT ticker, minute::TIMESTAMPTZ + INTERVAL 1 MINUTE AS bar_end, yes_ask_close AS ask
FROM read_parquet('kalshieye-data/bbo_1m/date=*/series=KXBTC15M/*.parquet')
WHERE yes_ask_close IS NOT NULL
),
entries AS (
SELECT b.ticker, ask, v
FROM b JOIN s USING (ticker)
WHERE bar_end <= close_ts - INTERVAL 5 MINUTE
QUALIFY row_number() OVER (PARTITION BY b.ticker ORDER BY bar_end DESC) = 1
)
SELECT count(*) AS markets,
round(sum(10 * (v - ask) - ceil(round(0.07 * 10 * ask * (1 - ask) * 100, 6)) / 100), 2) AS pnl
FROM entries; -- 95 markets, -40.85Things to know
- Spread. Bars at the ask are right for buys only. A rule that also sells needs the bid for the sells.
- Size. Minute bars carry no size. Check that the ask held 10 contracts in bbo, or walk the book in NautilusTrader.
- Gaps. Leave out hours listed in incidents; on this day the recorder missed part of 10 AM UTC.
- Early closes. The entry is placed from the settlement's
close_ts, the time the market really closed.
Questions
Why one backtest per market?
backtesting.py tests one instrument at a time, and each Kalshi market is its own instrument with its own end. A 15-minute market has about a dozen bars, so a day of them runs in a few seconds.
Can I use the mid or the bid instead of the ask?
For prices you could have traded, buy at the ask and sell at the bid. A rule that buys and sells before the close needs both: use yes_ask_close for buys and yes_bid_close for sells, or test it in NautilusTrader, which keeps the two sides apart.
Next
- How to backtest Kalshi markets: the same rule in SQL, and the mistakes that flatter results.
- Backtest with an AI agent: have a coding agent run and extend this.