CodeFix Solution

Working With Kalshi Order-Book Data in Python: L2 Depth + Trade Prints, Joined

July 20, 2026 · 11 min read

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

FeedOne row is…Answers
depth/(snapshot, ticker, side, level)What would it have cost to trade size?
trades/one executed printWhat 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:

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.

Get the $9 tryout →