Working With Kalshi Order-Book Data in Python: L2 Depth + Trade Prints, Joined
If you've tried to do microstructure research on Kalshi, you've hit the wall: the public API gives you trades and candles, but no book history. There is no endpoint for "show me the ladder at 19:42:31 last Tuesday." Book state has to be recorded live, which means working from a capture — yours or someone else's.
This guide walks through the practical Python for working with one such capture: daily parquet files of L2 depth (up to 20 levels per side, ~28-second snapshots) plus the executed trade tape (prints with taker side and a capture-time game-state field). The patterns apply to any book+tape dataset, but the code below runs as-is against the Kalshi Microstructure Tape (there's a $9 two-day slice with an identical schema if you want to follow along without committing to the full archive).
The two feeds, and why you want both
| Feed | One row is… | Answers |
|---|---|---|
depth/ | (snapshot, ticker, side, level) | What would it have cost to trade size? |
trades/ | one executed print | What did people actually do? |
Depth without prints is a photograph. Prints without depth are footsteps with no floor plan. Most public prediction-market datasets give you neither — at best a last-price series, which can't distinguish a 1-lot from a 5,000-lot move.
Loading a day of depth
import pandas as pd
depth = pd.read_parquet("depth/kalshi_depth_2026-07-18.parquet")
# columns: timestamp (epoch s), ticker, sport, market_type, line,
# side ('bid'/'ask'), level (0=best), price_c (cents), size
# family = the ticker prefix, e.g. KXMLBGAME-26JUL12-...
depth["family"] = depth["ticker"].str.split("-").str[0]
depth["ts"] = pd.to_datetime(depth["timestamp"], unit="s", utc=True)
# top of book for one market
tob = (depth[depth["level"] == 0]
.pivot_table(index=["ticker", "ts"], columns="side",
values="price_c", aggfunc="first")
.rename(columns={"bid": "best_bid_c", "ask": "best_ask_c"}))
tob["spread_c"] = tob["best_ask_c"] - tob["best_bid_c"]
Two gotchas that bite everyone the first time:
- Levels are capped at 20 per side. If your VWAP-to-size calculation walks off level 19, the answer is "deeper than the data shows," not a fill price. Handle that case explicitly.
- The per-print
game_stateis capture-time, not event-time. The poller attaches whatever scoreboard state exists when it retrieves the print — median ~26s aftercreated_time, with a long tail on backfills. Join to your own score timeline atcreated_time(which is exactly what themerge_asofpattern below does for books). - Snapshots are polled, not event-driven. A gap between rows
means "no observation," never "no change." Resample with
.asof()-style joins, not forward-fill over long gaps.
The trade tape: two timestamps, one trap
trades = pd.read_parquet("trades/kalshi_trades_2026-07-18.parquet")
# created_time = when the EXCHANGE matched it <-- use this
# capture_ts = when the recorder saw it <-- latency diagnostics only
trades["t"] = pd.to_datetime(trades["created_time"], utc=True)
mlb = trades[trades["ticker"].str.startswith("KXMLBGAME")]
Every polled tape has duplicate risk (re-poll windows overlap). A good capture
dedupes by the exchange's trade_id before shipping; verify anyway,
because it's one line:
assert trades["trade_id"].is_unique
Joining prints to the book they hit
The core microstructure join: attach each print to the last book snapshot at or
before it. That's merge_asof:
book = tob.reset_index().sort_values("ts")
prints = mlb.sort_values("t")
joined = pd.merge_asof(
prints, book,
left_on="t", right_on="ts", by="ticker",
direction="backward", tolerance=pd.Timedelta("60s"),
)
# aggressive buys hitting the ask vs sells hitting the bid
joined["at_ask"] = joined["price"] >= joined["best_ask_c"]
With a ~28-second snapshot cadence, alignment precision is bounded by the cadence — fine for flow-imbalance and spread studies, not for queue-position claims. Know what your data can and cannot support; overclaiming resolution is the most common bug in amateur microstructure work.
A first real feature: signed flow vs. next repricing
joined["signed"] = joined["count"] * joined["at_ask"].map({True: 1, False: -1})
flow = (joined.set_index("t")
.groupby("ticker")["signed"]
.resample("5min").sum())
mid = ((book.set_index("ts")["best_bid_c"] + book["best_ask_c"].values) / 2)
# ... then lag the flow one bar against forward mid changes and measure IC
Whether signed flow predicts anything on a sports book is genuinely open — these markets are efficient enough that the honest prior is "probably not much." But that's a question you can now answer with data instead of vibes, which is the entire point.
Know your dataset's gaps before you trust a result
Any live capture has holes — recorder restarts, host downtime, coverage that grew over time. The tape linked above ships a coverage file listing every known gap with UTC timestamps (including two multi-hour host-sleep windows in mid-July, and the fact that the print tape covers four moneyline families, with MLB starting July 10). Read that file first and mask those windows in your analysis, or your "signal" will be an artifact of a capture gap. If a data vendor doesn't publish its gaps, assume they exist anyway.
Free to inspect before buying: a 400-row depth sample and the complete schema doc are on zenhodl.net's samples page, no signup. The $9 tryout is two complete days of both feeds — enough to run everything in this post.
Build against real book + tape data
Two full days of Kalshi L2 depth and deduped prints, identical schema to the full archive.