Polymarket runs a perpetual futures exchange alongside its prediction-market order book (live since 2026-04-21, CFTC-regulated, crypto plus US equities, indices and commodities). We capture it on two independent collectors since 2026-07-31 and normalize six tables per UTC day. It is sold per day with an instrument filter: included with Premium, $29/mo on Explorer, one fixed free sample day (2026-08-12) for any account. Pull from /catalog/perps or the API below.
One parquet per table per instrument per day, in a Hive tree. The bundle keeps that layout, so a single read_parquet on a table directory reads every instrument and day you pulled, with date and symbol injected as columns from the path. In all six tables symbol is not a file column; the stored key is iid, and perps_instruments — always included — is the iid → symbol dictionary, read the same way as the others.
polymarket-perps-2026-08-12/ README.md perps_bbo/date=2026-08-12/symbol=BTC-USD/perps_bbo-2026-08-12-BTC-USD.parquet perps_bbo/date=2026-08-12/symbol=ETH-USD/… perps_trades/date=2026-08-12/symbol=BTC-USD/… perps_instruments/date=2026-08-12/symbol=BTC-USD/…
import pandas as pd
bbo = pd.read_parquet("perps_bbo") # date + symbol injected from the path
inst = pd.read_parquet("perps_instruments") # same layout, same read
spec = inst.sort_values("snapshot_ts_ms").groupby("iid").tail(1)[["iid", "symbol", "category", "max_leverage"]]
btc = bbo[bbo.symbol == "BTC-USD"].sort_values("ts_recv_ns")
btc["spread_bps"] = 1e4 * (btc.ask_px - btc.bid_px) / btc.midFive event tables and the dictionary. Every event table carries the same provenance tail (sq, emit_ts_ms, ts_recv_ns, cohort, payload_sha256). Choose any of bbo, book, book_deep, trades, tickers; the dictionary is always added.
Top of book on every change: bid/ask price and size, mid and spread, straight off the venue's bbo channel. ~17 messages a second per instrument, none of them redundant. ~1.0 GB · ~19M rows across all instruments.
The websocket ladder, 20 levels a side, as a complete snapshot roughly every 100 ms. The venue publishes no delta stream, so every row is a whole book; about a quarter of consecutive frames are byte-identical and are kept as sent. ~1.6 GB · ~23M rows across all instruments.
The full ladder from the REST book endpoint, polled about every 15 seconds — deeper than the 20-level websocket book (deepest observed ~71 levels) and carrying the venue's own sequence and timestamp. A separate table, never merged into perps_book. ~55 MB · ~300k rows across all instruments.
Every fill the venue printed: taker side, price, size, venue trade id and the settlement transaction hash once mined. The venue is young and thin — tens of thousands of trades a day across all instruments — so the book and ticker tables carry most of the information. ~3 MB · ~40k rows across all instruments.
Index, mark, last and mid price, open interest, the current funding rate and the next funding time, roughly every 100 ms per instrument. This is the ONLY place funding lives: the venue has no market-wide funding channel. ~1.3 GB · ~24M rows across all instruments.
The contract specifications and the iid → symbol mapping every other table is keyed on: category, base/quote asset, max leverage, tick/lot decimals, min notional, liquidation fee, risk tiers. Snapshotted every five minutes. Included with every pull. ~1 MB · ~16k rows across all instruments.
Books are snapshots, never deltas. The venue publishes no incremental stream. Every perps_book row is a complete 20-level ladder at ~100 ms and every perps_book_deep row a complete full ladder at ~15 s from REST; the two are separate tables and are never merged. About a quarter of consecutive perps_book frames are byte-identical and are kept as sent — derive a changed flag with df.sort_values("sq").duplicated(subset=["bid_px","bid_qty","ask_px","ask_qty"]).
sq is venue-global, not per-channel. One value fans out to many frames across channels. It orders events; it cannot detect gaps. There is no per-channel sequence number on this venue.
Funding lives only in tickers. funding_rate and next_funding_ms are on every perps_tickers row (~100 ms). There is no market-wide funding channel.
Coverage starts 2026-07-31 at 15:44 UTC, so that first day is partial. The universe grew from 34 to 67 instruments during August 2026; each instrument's own first day is on the catalog page and in the API.
KPEPE-USD is absent 2026-08-04 → 2026-08-05. Not captured on either collector for these two days: the venue's 34th listing pushed the subscription request past the 100-channels-per-connection cap and the excess instrument was silently rejected. Fixed by sharding subscriptions; no other symbol was affected.
Trades are thin. Tens of thousands of fills a day across the whole venue; the book and ticker tables carry most of the information.
Same bearer key and rate limits as the rest of /api/v1. One pull covers at most 31 days and 10 GB; the enqueue prices your exact selection from the per-instrument file listing and refuses over the cap with the figure, so narrow the instruments, drop a table, or split the range. Perps bundles have no CSV twin.
GET /api/v1/perps
→ { access, demo_date, first_date, last_date, days, limits,
datasets:[{dataset, label, days, first_date, last_date, bytes, record_count}],
instruments:[{symbol, category, first_date, last_date, days}],
known_gaps:[…] }
access: plan | addon | demo | requires_addon
POST /api/v1/perps/downloads
body { start, end, datasets:["perps_bbo" | "perps_book" | "perps_book_deep" | "perps_trades" | "perps_tickers"], symbols?:["BTC-USD", …] }
→ { status:"staging", id, poll, days, files, bytes, via }
poll GET /api/v1/downloads/{id} as for any other downloadimport requests, time
API = "https://tickfoundry.com/api/v1"; H = {"Authorization": "Bearer tf_live_…"}
job = requests.post(f"{API}/perps/downloads", headers=H, json={
"start": "2026-08-10", "end": "2026-08-16",
"datasets": ["perps_bbo", "perps_book", "perps_trades"],
"symbols": ["BTC-USD", "ETH-USD"],
}).json()
while (s := requests.get(f"{API}/downloads/{job['id']}", headers=H).json())["status"] == "staging":
time.sleep(10)
open("perps.zip", "wb").write(requests.get(s["url"], headers=H).content)