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 out3. 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
- Backtest with an AI agent: have a coding agent adapt this script and check it.
- hftbacktest: the same markets with queue position and latency.
- Kalshi order book data: how book_l2 works.