tickfoundry
capture livesign inget the data →
§ guide

How to backtest Polymarket
against the order book that was actually there.

python · pandasfree dataTickFoundry · 2026-09-12 · runs on the free sample, no account

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.

Follow along with the sample: BTC Up or Down 4h · 2026-08-19 · full day of L1 / L2 / trades.
↓ parquet bundle (23.0 MB)↓ slippage.py

Why a price series is not enough

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.

Step 1 · Get the historical order book

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:

Free sample, no account

A full day of a Bitcoin market: l1, l2, trades, one hour of the raw feed, reference tables.

Download →
5 free market·days

Any market, any date in the catalog, on a free account. Email and password, no card.

Choose a market →
REST API

Explorer and up: list the venue, request a market·day, stream the bundle. Snippet below.

API reference →
request a market·day over the APIpython
import time, requests
API = "https://tickfoundry.com/api/v1" # Explorer and up
S = requests.Session()
S.headers["Authorization"] = "Bearer tf_live_…" # minted in the dashboard
unit = 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.

Step 2 · Load it in pandas

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.

load.pypython
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 time
book = pd.read_parquet("l2.parquet") # full 25-level snapshot per book update
trades = 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 nothing
tokens = 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) / 2
l1["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.

Step 3 · Simulate fills by walking the book

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 · the script behind the tablepython
# 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 np
import pandas as pd
l2 = pd.read_parquet("l2.parquet") # one full snapshot per book update
markets = 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 depth
ask_sz = book[[f"ask_sz_{i}" for i in range(1, 26)]].to_numpy() # size is in shares
def buy_vwap(notional_usd):
"""Average price paid for a market buy of notional_usd, walking the ask
ladder snapshot by snapshot. NaN where the visible book is too thin."""
cost = ask_px * ask_sz # $ resting at each level
cum = 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_usd
with np.errstate(divide="ignore"):
return np.where(enough, notional_usd / shares, np.nan)
mid = (book.best_bid + book.best_ask) / 2
for 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 buyfillable snapshotsvs mid · medianvs mid · p90vs best ask · medianvs best ask · p90
$10099.9%1.09¢2.00¢0.17¢0.72¢
$50099.9%2.36¢3.81¢1.48¢2.72¢
$2,00099.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.

Step 4 · Rules that make the backtest honest

Price every order off the snapshot in force at its receive time
Use ts_recv_ns for ordering, never ts_src_ms; look up the last l2 row at or before the decision time and walk it, as above.
Model both legs
A market has a YES and a NO token with their own books. Selling YES and buying NO are different fills; check which book is deeper.
Cap size by resting depth
If the ladder cannot absorb the order, the remainder does not fill. The fillable column above is how often that happens even on a liquid day.
Separate signal from execution
1-minute candles (in every bundle) are fine for the signal. Execution needs the book. Mixing them is how mid-fill optimism creeps back in.
Check the resolution source
reference/markets.csv carries the market's description, including the Chainlink TWAP rule for the crypto series; your labels must match how the market actually settled.
Treat the tape's fee column as 0
fee_rate_bps is reported as 0 on the socket tape; fee-enabled markets need the venue's schedule applied in your model.

Where the mid-fill assumption breaks hardest

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.

Read the Spain–Belgium note →The final through the order book

FAQ

Does Polymarket provide historical order book data?
No. Polymarket's order book runs off-chain and its public endpoints serve sampled price points, not book snapshots; the live websocket is not recorded for you, so depth cannot be fetched retroactively. Only what someone was recording at the time exists. TickFoundry has captured the whole venue continuously since 2026-05-11, with order-book history back to February 2026.
Can I backtest Polymarket for free?
Yes. The sample bundle is a full UTC day of a Bitcoin market (L1, 25-level L2, trades, one hour of the raw feed and the reference tables) and downloads without an account. A free account adds 5 market·days of your own choosing from the catalog. No card.
What does a Polymarket backtest need beyond a price series?
Three things a price chart cannot give you: the spread at the moment you would have traded, the size resting at each level (so a market order's real average price), and the arrival order of quotes and fills. All three are in the L2 and trades files, timestamped at nanosecond receive time.
Which Python tools work with the data?
Bundles are parquet with a CSV twin, so pandas, polars, DuckDB and Arrow read them directly; the walkthrough on this page uses pandas and numpy only. Adapters for hftbacktest and NautilusTrader are planned; until then, feed each engine its snapshot or delta input from the l2 columns.
How are trades and fees reported?
trades.parquet is the venue's own fill tape as it was broadcast: price, size (shares), aggressor side and a transaction hash. The fee_rate_bps column on the tape is reported as 0; if your market is fee-enabled, apply the venue's schedule in your model. On-chain settled fills, back to 2023, are a separate layer on Premium and up.
Run it on the market you actually trade.

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.

Choose your free dataset →Plans for the whole venue