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.

In [1]:
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.

In [2]:
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)")
No description has been provided for this image
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.

In [3]:
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()
No description has been provided for this image

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.

In [4]:
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.