Skip to content
Recording 7 venues

Backtest Kalshi order books in NautilusTrader

NautilusTrader is an open-source, event-driven trading engine with an order book matching engine: a market order walks the levels, a limit order rests. To use it on Kalshi it needs the book as it was, every level at every moment. This guide feeds it Kalshi Eye's book_l2 files, models each market as a binary option, charges Kalshi's fee and settles every position at 0 or 1.

Updated November 13, 2026. Run as shown on the free sample with nautilus_trader 1.231.0 and Python 3.12.

What you need

pip install "nautilus_trader==1.231.0" "kalshieye[pandas]" duckdb

The data is the free sample, BTC 15 min (KXBTC15M) on October 25, 2026: any Kalshi Eye account downloads every dataset of it, the full order book included. Other days of book_l2 are on Full book.

from kalshieye import Client

Client().download(["book_l2", "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 markets

One day of KXBTC15M book_l2 is about 40 million rows, so load only the markets you test. This takes four in a row, closing at noon, 12:15, 12:30 and 12:45 PM Eastern (the time in each ticker), with how each one settled.

import math

import duckdb
import pandas as pd

from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
from nautilus_trader.backtest.models import FeeModel
from nautilus_trader.config import LoggingConfig, StrategyConfig
from nautilus_trader.model.currencies import USD
from nautilus_trader.model.data import InstrumentClose, OrderBookDeltas
from nautilus_trader.model.enums import (
    AccountType, AssetClass, BookType, InstrumentCloseType, OmsType, OrderSide, TimeInForce,
)
from nautilus_trader.model.identifiers import InstrumentId, Symbol, TraderId, Venue
from nautilus_trader.model.instruments import BinaryOption
from nautilus_trader.model.objects import Money, Price, Quantity
from nautilus_trader.persistence.wranglers import OrderBookDeltaDataWrangler
from nautilus_trader.trading.strategy import Strategy

DATA = "kalshieye-data"
SERIES = "KXBTC15M"
TICKERS = [
    "KXBTC15M-26OCT251200-00",
    "KXBTC15M-26OCT251215-15",
    "KXBTC15M-26OCT251230-30",
    "KXBTC15M-26OCT251245-45",
]
KALSHI = Venue("KALSHI")

con = duckdb.connect()
picked = ", ".join(f"'{t}'" for t in TICKERS)
settled = con.sql(f"""
    SELECT ticker, settlement_value, epoch_ns(close_ts) AS close_ns
    FROM read_parquet('{DATA}/settlements/date=*/*.parquet')
    WHERE ticker IN ({picked}) AND result IN ('yes', 'no')
""").df().set_index("ticker")
book = con.sql(f"""
    SELECT ticker, epoch_ns(ts_recv) AS ts, seq, is_snapshot, side, price, size
    FROM read_parquet('{DATA}/book_l2/date=*/series={SERIES}/*.parquet')
    WHERE ticker IN ({picked})
    ORDER BY ticker, ts_recv, seq
""").df()
con.close()

2. Turn book_l2 into order book deltas

Kalshi's book is bids only. A YES bid at 22¢ is a bid; a NO bid at 78¢ is an offer to sell YES at 22¢, so NO bids become YES asks at 1 − p. Snapshot rows give the size resting at a level and delta rows the change, while NautilusTrader takes the size itself, so a running sum per level since the last snapshot gives it. Each snapshot starts with a CLEAR.

def to_deltas(rows: pd.DataFrame) -> pd.DataFrame:
    """One market's book_l2 rows as Nautilus L2 deltas, in YES terms."""
    starts = rows["is_snapshot"] & (rows["ts"] != rows["ts"].shift())  # first row of each snapshot
    r = rows.assign(snap=starts.cumsum())
    r = r[r["side"].notna()]  # an empty snapshot only clears the book
    # Snapshot rows hold the size at a level, deltas the change. A running sum per level since
    # the last snapshot is the size, which is what Nautilus takes.
    size = r.groupby(["snap", "side", "price"])["size"].cumsum().round(2).clip(lower=0)
    out = pd.DataFrame({
        "ts": r["ts"],
        "action": "UPDATE",
        "side": r["side"].map({"yes": "BUY", "no": "SELL"}),
        # YES bids are bids. A NO bid at p is a YES ask at 1 - p.
        "price": r["price"].where(r["side"] == "yes", 1 - r["price"]).round(2),
        "size": size,
        "order_id": 0,
        "flags": 0,
        "sequence": r["seq"],
    })
    out.loc[r["is_snapshot"].values, "action"] = "ADD"
    out.loc[out["size"] <= 0, "action"] = "DELETE"
    clears = rows.loc[starts, ["ts", "seq"]].rename(columns={"seq": "sequence"}).assign(
        action="CLEAR", side="NO_ORDER_SIDE", price=0.0, size=0.0, order_id=0, flags=0)
    out = pd.concat([clears, out]).sort_values(["ts", "sequence"], kind="stable")  # each snapshot clears first
    out.index = pd.to_datetime(out.pop("ts"), utc=True)
    return out

3. A binary option per market, and Kalshi's fee

Each market is a BinaryOption in YES terms: prices in cents, sizes in hundredths of a contract, expiring at the market's close. Kalshi charges takers 0.07 × P × (1 − P) a contract, rounded up to the cent; the fee model does that and charges nothing when a position settles.

def binary_option(ticker: str, close_ns: int) -> BinaryOption:
    """A Kalshi market as YES contracts: prices in cents, sizes in hundredths of a contract."""
    return BinaryOption(
        instrument_id=InstrumentId(Symbol(ticker), KALSHI),
        raw_symbol=Symbol(ticker),
        asset_class=AssetClass.ALTERNATIVE,
        currency=USD,
        price_precision=2,
        price_increment=Price.from_str("0.01"),
        size_precision=2,
        size_increment=Quantity.from_str("0.01"),
        activation_ns=0,
        expiration_ns=close_ns,
        ts_event=0,
        ts_init=0,
        outcome="Yes",
    )


class KalshiFee(FeeModel):
    """Kalshi's taker fee, 0.07 x P x (1 - P) a contract rounded up to the cent. Settling is free."""

    def get_commission(self, order, fill_qty, fill_px, instrument):
        if order.client_order_id.value.startswith("EXPIRATION"):
            return Money(0, USD)
        p, q = float(fill_px), float(fill_qty)
        return Money(math.ceil(round(0.07 * q * p * (1 - p) * 100, 6)) / 100, USD)

4. The strategy

It buys 10 YES contracts at the ask once, five minutes before the close. With an L2 book, a market order larger than the best ask fills at the next levels too.

class BuyYesConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    close_ns: int
    minutes_before: int = 5
    contracts: int = 10


class BuyYes(Strategy):
    """Buys YES at the ask once, minutes_before the market closes."""

    def __init__(self, config: BuyYesConfig) -> None:
        super().__init__(config)
        self.done = False

    def on_start(self) -> None:
        self.subscribe_order_book_deltas(self.config.instrument_id, BookType.L2_MBP)

    def on_order_book_deltas(self, deltas: OrderBookDeltas) -> None:
        entry_ns = self.config.close_ns - self.config.minutes_before * 60 * 10**9
        if self.done or deltas.ts_event < entry_ns:
            return
        if self.cache.order_book(self.config.instrument_id).best_ask_price() is None:
            return  # nobody selling yet
        self.done = True
        instrument = self.cache.instrument(self.config.instrument_id)
        self.submit_order(self.order_factory.market(
            instrument_id=instrument.id,
            order_side=OrderSide.BUY,
            quantity=instrument.make_qty(self.config.contracts),
            time_in_force=TimeInForce.IOC,
        ))

5. Run it and settle

A cash account in dollars, the L2 book, and the settlement prices. In 1.231.0 a binary option does not expire on its own, so each market also gets an InstrumentClose at its close time: the venue then closes the position at the settlement price, 1 for YES and 0 for NO.

engine = BacktestEngine(BacktestEngineConfig(trader_id=TraderId("BACKTEST-001"), logging=LoggingConfig(log_level="ERROR")))
engine.add_venue(
    venue=KALSHI,
    oms_type=OmsType.NETTING,
    account_type=AccountType.CASH,
    base_currency=USD,
    starting_balances=[Money(1_000, USD)],
    book_type=BookType.L2_MBP,  # every price level, so a big order walks the book
    fee_model=KalshiFee(),
    settlement_prices={InstrumentId(Symbol(t), KALSHI): float(settled.loc[t, "settlement_value"]) for t in TICKERS},
)
for t in TICKERS:
    close_ns, value = int(settled.loc[t, "close_ns"]), float(settled.loc[t, "settlement_value"])
    market = binary_option(t, close_ns)
    engine.add_instrument(market)
    engine.add_data(OrderBookDeltaDataWrangler(market).process(to_deltas(book[book["ticker"] == t])))
    # Binary options don't expire on their own: closing the instrument at its close time
    # settles the position at the settlement price given to the venue (1 or 0).
    engine.add_data([InstrumentClose(market.id, Price(value, 2), InstrumentCloseType.CONTRACT_EXPIRED, close_ns, close_ns)])
    engine.add_strategy(BuyYes(BuyYesConfig(instrument_id=market.id, close_ns=close_ns)))

engine.run()

positions = engine.trader.generate_positions_report()
print(positions[["instrument_id", "avg_px_open", "realized_pnl"]].to_string(index=False))
print("balance:", engine.trader.generate_account_report(KALSHI)["total"].iloc[-1])

It prints:

                 instrument_id  avg_px_open realized_pnl
KXBTC15M-26OCT251200-00.KALSHI      0.22000    -2.33 USD
KXBTC15M-26OCT251215-15.KALSHI      0.21000    -2.22 USD
KXBTC15M-26OCT251230-30.KALSHI      0.05898    -0.64 USD
KXBTC15M-26OCT251245-45.KALSHI      0.36000     6.23 USD
balance: 1001.04

Three markets settled NO and one YES. The 12:30 PM market had only a few contracts at the 5¢ ask, so the order took the next levels and paid 5.9¢ on average. Starting from $1,000, the balance ends at $1,001.04: $10 from the market that settled YES, less $8.49 paid for contracts and $0.47 in fees. Four markets prove the code, not the rule; run it over many days before you trust a number.

Things to know

  • Fees round per fill. NautilusTrader asks the fee model once for each fill. Kalshi rounds per order, so an order that fills at several levels pays a cent or two more here than on Kalshi.
  • YES only. Buying NO at p is selling YES at 1 − p. To hold NO, model it as a second binary option whose book is the mirror of this one.
  • Gaps. Leave out hours listed in incidents: a book carried through an hour the recorder missed looks tradable.
  • Latency. Orders here reach the book the instant the strategy sends them. Add a latency model to the venue to test slower orders.

Questions

Does NautilusTrader have Kalshi order book history?

No. A backtest that fills orders against the book needs the book as it was, and Kalshi's API keeps no past order books. Kalshi Eye's book_l2 files hold every level and every change, recorded from October 12, 2026.

Why 1.231.0?

It is the current stable release. The 2.0 pre-releases change imports and how data is loaded, so code written for one does not run on the other. Pin the version you test with.

Can I run Polymarket books the same way?

Yes, with polymarket_l2. Polymarket books are two-sided with absolute sizes, so there is no NO-to-ask flip and no running sum: each row maps to one delta. NautilusTrader's own Polymarket adapter also models each outcome token as a BinaryOption.

Next