Most Polymarket backtests fill at the last price or the mid, because that is all a price series can offer. On a market that repriced 64 points in 0.65 seconds with the spread at 31¢, that assumption is the whole result. This guide does it properly: get the historical order book, load it in pandas, and price every simulated order by walking the levels that were resting at that instant. Every number below comes from the free sample day and the script shown, so you can reproduce it before you sign up for anything.
Polymarket's order book runs off-chain. The public API serves sampled prices, and the websocket that carries book updates is not recorded for you, so once an update scrolls past it is gone. A backtest built on prices alone has to invent three things: the spread at the moment of the trade, the size that was actually resting, and the order in which quotes and fills arrived. On the sample day's busiest market the time-weighted spread was 1.73¢ and the median size at the best ask was $50. A $500 market buy therefore never fills at the touch; it walks about five levels.
In calm markets that is a few cents. Around news it is the trade. In the Spain–Belgium quarterfinal the book at the touch thinned to about $1.3k and the spread blew out to 31¢ while the mid moved 64 points in 0.65 seconds; a mid-fill backtest books a profit that no order could have captured. That note is linked at the end.
One download covers one market (a series such as "Bitcoin Up or Down 4h" or a standalone event) for one UTC day, as parquet with a CSV twin. Three ways in, in increasing order of commitment:
A full day of a Bitcoin market: l1, l2, trades, one hour of the raw feed, reference tables.
Download →Any market, any date in the catalog, on a free account. Email and password, no card.
Choose a market →Explorer and up: list the venue, request a market·day, stream the bundle. Snippet below.
API reference →import time, requestsAPI = "https://tickfoundry.com/api/v1" # Explorer and upS = requests.Session()S.headers["Authorization"] = "Bearer tf_live_…" # minted in the dashboardunit = S.get(f"{API}/catalog", params={"q": "bitcoin up or down", "kind": "series"}).json()["units"][0]["id"]job = S.post(f"{API}/downloads", json={"unit_id": unit, "date": "2026-08-19"}).json()while job["status"] == "staging":time.sleep(job.get("retry_after", 5))job = S.get(f"{API}/downloads/{job['id']}").json()open("bundle.zip", "wb").write(S.get(job["url"]).content)
Coverage: order books and trades captured live since 2026-05-11, book history back to February 2026, on-chain fills back to 2023-08, per market — thinner before 2026.
Every file carries ts_recv_ns, the collector's receive time in nanoseconds, which is the authoritative ordering; ts_src_ms is the venue's own millisecond stamp. Rows key on token_id (one per outcome, so a market has a YES and a NO leg) and condition_id (the market). The reference tables turn both into the question text.
import pandas as pd# the free sample: https://tickfoundry.com/samples (no account needed)l1 = pd.read_parquet("l1.parquet") # every top-of-book change, ns receive timebook = pd.read_parquet("l2.parquet") # full 25-level snapshot per book updatetrades = pd.read_parquet("trades.parquet") # every fill the venue reported# token_id is a 77-digit integer — read it as a string or the join matches nothingtokens = pd.read_csv("reference/tokens.csv", dtype={"token_id": str})markets = pd.read_csv("reference/markets.csv")l1 = l1.merge(tokens[["token_id", "market_id", "outcome_label"]], on="token_id")l1["mid"] = (l1.best_bid + l1.best_ask) / 2l1["ts"] = pd.to_datetime(l1.ts_recv_ns, unit="ns", utc=True)
Full column reference in the schema docs; an executed notebook over this same bundle is at /quickstart.html.
l2.parquet holds one full snapshot per book update: ask_px_1..25 and ask_sz_1..25 (and the bid side), padded with NaN past the deepest level present. Pricing a market buy is then arithmetic: take dollars from each level until the order is filled, and divide. The script below does it for every snapshot of the day at three order sizes, which is the distribution of execution cost your strategy would actually have faced.
# slippage.py — walk the historical L2 book for a market buy and compare# the price you would actually have paid with the price a naive backtest assumes.import numpy as npimport pandas as pdl2 = pd.read_parquet("l2.parquet") # one full snapshot per book updatemarkets = pd.read_csv("reference/markets.csv")# Pick one outcome token: the YES leg of the busiest market of the day.busiest = l2.groupby("condition_id").size().idxmax()yes = str(markets.set_index("condition_id").loc[busiest, "yes_token_id"])book = l2[l2.token_id == yes].sort_values("ts_recv_ns").reset_index(drop=True)ask_px = book[[f"ask_px_{i}" for i in range(1, 26)]].to_numpy() # per level, NaN past depthask_sz = book[[f"ask_sz_{i}" for i in range(1, 26)]].to_numpy() # size is in sharesdef buy_vwap(notional_usd):"""Average price paid for a market buy of notional_usd, walking the askladder snapshot by snapshot. NaN where the visible book is too thin."""cost = ask_px * ask_sz # $ resting at each levelcum = np.nancumsum(cost, axis=1)filled = np.minimum(cost, np.maximum(notional_usd - (cum - cost), 0))shares = np.nansum(filled / ask_px, axis=1)enough = np.nanmax(cum, axis=1) >= notional_usdwith np.errstate(divide="ignore"):return np.where(enough, notional_usd / shares, np.nan)mid = (book.best_bid + book.best_ask) / 2for usd in (100, 500, 2000):vwap = buy_vwap(usd)print(usd, f"fillable {np.mean(~np.isnan(vwap)):.1%}",f"median slip vs mid {np.nanmedian((vwap - mid) * 100):.2f}¢",f"vs best ask {np.nanmedian((vwap - book.best_ask) * 100):.2f}¢")
| market buy | fillable snapshots | vs mid · median | vs mid · p90 | vs best ask · median | vs best ask · p90 |
|---|---|---|---|---|---|
| $100 | 99.9% | 1.09¢ | 2.00¢ | 0.17¢ | 0.72¢ |
| $500 | 99.9% | 2.36¢ | 3.81¢ | 1.48¢ | 2.72¢ |
| $2,000 | 99.0% | 4.94¢ | 7.28¢ | 3.83¢ | 6.28¢ |
YES leg of "Bitcoin Up or Down - August 19, 4:00PM-8:00PM ET" · 93,016 book snapshots · 2026-08-19 UTC · slippage in cents of probability, average fill price minus the reference.
Read it against the assumption most backtests make. A mid-fill model prices a $2,000 buy about 5¢ too low half the time and more than 7¢ too low one snapshot in ten, on a contract that trades near 50¢. Even "fill at the best ask" is optimistic once the order is bigger than the touch: the median $2,000 buy walks 3.8¢ past it. And 1% of the time the visible book could not absorb $2,000 at all. None of this is visible in a price chart, and all of it compounds over a strategy's lifetime.
The World Cup quarterfinal on 2026-07-10: Merino scores in the 89th minute, "Will Spain win" goes from 26¢ to 90¢, 80% of the move inside 0.65 seconds, spread out to 31¢, book at the touch down to about $1.3k. The first aggressive fill landed 35 milliseconds before the book itself had visibly repriced. A backtest that fills at the price series books an entry no order could have got; the ladder says what was actually available.
The sample is one Bitcoin day. A free account claims 5 market·days of your choosing from every market on the venue, in the same schema, and this script runs on any of them unchanged.