Polymarket order-book data — 5-minute quickstart¶
This notebook loads a free one-hour sample of Polymarket order-book data and plots the three things people usually want first: mid price, quoted spread, and how much size is actually resting at the touch — plus a depth heatmap of the full 25-level book.
The bundle is a one-hour slice of the Bitcoin Up or Down — 4 hour series (2026-05-19 14:00–15:00 UTC). It contains:
| file | what it is |
|---|---|
raw.jsonl |
the exact inbound Polymarket websocket messages, one per line |
l1.parquet |
top-of-book updates: best bid/ask + sizes |
l2.parquet |
book snapshots, up to 25 levels per side (deeper books are truncated — check bid_levels/ask_levels) |
trades.parquet |
one row per matched fill |
reference/ |
series / events / markets / tokens metadata |
Every record carries ts_recv_ns — a nanosecond receive timestamp, which is
what you order on — and ts_src_ms, the upstream timestamp when present.
Requirements: pandas, pyarrow, matplotlib. Put the sample zip next to
this notebook (grab it from tickfoundry.com/samples) and run top to bottom.
import zipfile, io, pandas as pd, numpy as np, matplotlib.pyplot as plt
BUNDLE = "polymarket-btc-4h-2026-05-19-14utc.zip"
z = zipfile.ZipFile(BUNDLE)
root = "polymarket-btc-4h-2026-05-19-14utc"
load = lambda name: pd.read_parquet(io.BytesIO(z.read(f"{root}/{name}")))
l1, l2, trades = load("l1.parquet"), load("l2.parquet"), load("trades.parquet")
for name, df in [("l1", l1), ("l2", l2), ("trades", trades)]:
print(f"{name:>7}: {len(df):>8,} rows {df.ts_recv_ns.min()} .. {df.ts_recv_ns.max()}")
# One market has two outcome tokens (YES/NO). Take the busiest one.
tok = l1.token_id.value_counts().index[0]
print(f"\nbusiest token: {tok} ({l1.token_id.value_counts().iloc[0]:,} L1 updates)")
l1: 10,474 rows 1779199200360501376 .. 1779202799085114111
l2: 137,034 rows 1779199200360501376 .. 1779202799992664326
trades: 168 rows 1779199209819074233 .. 1779202794856181743
busiest token: 1957249917145011222482611643890770635130530233476041071405926605484924485648 (5,157 L1 updates)
1. Mid, spread, and size at the touch¶
best_bid_size / best_ask_size are contract counts, not dollars. A contract
pays $1 if the outcome resolves YES. Below we chart bid*bid_size + ask*ask_size:
that is what a taker would pay in cash to lift everything quoted at the touch,
and it is the figure to compare against a dollar order size. It is not what the
makers posted — a resting offer is collateralised at (1-price)*size, not
price*size — so don't read this line as "capital committed to the book".
Note also that spread in the file is rounded to three decimals and cannot
represent a 0.25¢ tick (it stores 0.003). We compute the spread from
best_ask - best_bid here, which is exact.
You will see a couple of momentarily crossed or locked quotes in the spread
panel (here: 2 crossed and 9 locked out of 5,157 updates). Those are real — the
book genuinely passes through those states between messages, and this archive
records what arrived rather than a cleaned-up version of it. If your strategy
cares, filter on best_ask > best_bid yourself; the point is that the choice
is yours rather than ours.
d = l1[l1.token_id == tok].sort_values("ts_recv_ns").copy()
d["t"] = pd.to_datetime(d.ts_recv_ns, unit="ns", utc=True)
d["mid"] = (d.best_bid + d.best_ask) / 2
d["spread_c"] = (d.best_ask - d.best_bid) * 100 # exact; do not use the rounded `spread` column
d["touch_usd"] = d.best_bid * d.best_bid_size + d.best_ask * d.best_ask_size
fig, ax = plt.subplots(3, 1, figsize=(11, 8), sharex=True)
ax[0].plot(d.t, d.mid * 100, lw=1.2, color="#7dffb0"); ax[0].set_ylabel("mid (¢)")
ax[1].plot(d.t, d.spread_c, lw=1.0, color="#fbbf24"); ax[1].set_ylabel("spread (¢)")
ax[2].plot(d.t, d.touch_usd, lw=1.0, color="#67e8f9"); ax[2].set_ylabel("cost to lift touch ($)")
ax[2].set_yscale("log"); ax[2].set_xlabel("time (UTC)")
for a in ax: a.grid(alpha=0.25)
ax[0].set_title(f"BTC 4h — token {str(tok)[:12]}…", loc="left")
plt.tight_layout(); plt.show()
print(f"median spread : {d.spread_c.median():.2f}¢")
print(f"median size @ touch: ${d.touch_usd.median():,.0f} "
f"({(d.best_bid_size + d.best_ask_size).median():,.0f} contracts)")
median spread : 3.00¢ median size @ touch: $515 (681 contracts)
2. Depth heatmap — the full 25-level book¶
l2.parquet is one row per book update, each a complete snapshot: bid_px_1..25
/ bid_sz_1..25 and the same for asks, padded with NaN past the deepest level
present. Melting that into (time, price, size) gives a standard depth heatmap.
b = l2[l2.token_id == tok].sort_values("ts_recv_ns")
px = np.concatenate([b[[f"bid_px_{i}" for i in range(1, 26)]].to_numpy(),
b[[f"ask_px_{i}" for i in range(1, 26)]].to_numpy()], axis=1)
sz = np.concatenate([b[[f"bid_sz_{i}" for i in range(1, 26)]].to_numpy(),
b[[f"ask_sz_{i}" for i in range(1, 26)]].to_numpy()], axis=1)
t = np.repeat(b.ts_recv_ns.to_numpy()[:, None], px.shape[1], axis=1)
ok = np.isfinite(px) & np.isfinite(sz) & (sz > 0)
tb = np.linspace(t.min(), t.max(), 220)
pb = np.arange(0, 100.5, 1.0)
H, _, _ = np.histogram2d(t[ok], px[ok] * 100, bins=[tb, pb], weights=sz[ok])
N, _, _ = np.histogram2d(t[ok], px[ok] * 100, bins=[tb, pb])
fig, ax = plt.subplots(figsize=(11, 5.5))
im = ax.pcolormesh(pd.to_datetime(tb[:-1], unit="ns", utc=True), pb[:-1],
np.log10(np.divide(H, np.maximum(N, 1)).T + 1),
cmap="magma", shading="auto")
ax.plot(d.t, d.mid * 100, color="#7dffb0", lw=1.0, label="mid")
ax.set_ylabel("price (¢)"); ax.set_xlabel("time (UTC)"); ax.legend(loc="upper left")
ax.set_title("resting size across the book — log10(1 + mean contracts per cell)", loc="left")
fig.colorbar(im, ax=ax, pad=0.01); plt.tight_layout(); plt.show()
3. Trades¶
trades.parquet is one row per last_trade_price message — the venue's print
tape — with price, size, side and the transaction_hash. A single
aggressing order that sweeps several resting orders is published as one print,
not one row per maker filled, so treat this as the tape rather than a complete
per-counterparty fill log. Fees on Polymarket fills are zero, so notional is
just price × size.
tr = trades[trades.token_id == tok].copy()
tr["notional"] = tr.price * tr["size"]
print(f"{len(tr):,} fills ${tr.notional.sum():,.0f} notional "
f"median fill ${tr.notional.median():,.2f}")
print(tr.groupby("side").agg(fills=("price", "size"), notional=("notional", "sum")))
100 fills $5,380 notional median fill $4.40
fills notional
side
BUY 81 4450.029133
SELL 19 930.315800
Where this data comes from¶
Recorded from Polymarket's public market websocket
(wss://ws-subscriptions-clob.polymarket.com/ws/market), preserved verbatim in
raw.jsonl and normalized into the L1/L2/trades parquet layers. Timestamps in
this notebook are our receive times, not exchange matching times.
Full schema documentation is in README.md inside the bundle.
Disclosure: I built TickFoundry, which produced this sample.