大多数 Polymarket 回测都按最新成交价或中间价成交,因为价格序列能提供的也就只有这些。可在一个 0.65 秒内重新定价 64 个点、价差被拉到 31¢ 的市场上,这个假设本身就决定了整个回测结果。本教程用正确的方法来做:获取历史订单簿,用 pandas 加载,并按那一刻实际挂着的各档价位逐档吃单,为每一笔模拟订单定价。下文每个数字都来自免费样本日和文中展示的脚本,你在注册任何东西之前就能自己复现。
Polymarket 的订单簿在链下运行。公开 API 只提供采样后的价格,承载订单簿更新的 WebSocket 也不会替你留存,一条更新一旦过去就再也找不回来。只基于价格的回测不得不凭空假设三样东西:成交那一刻的价差、实际挂着的数量,以及报价和成交到达的先后顺序。在样本日最活跃的市场上,时间加权价差为 1.73¢,最优卖价上挂单数量的中位数是 $50。因此一笔 $500 的市价买单永远不可能全部在最优价成交,它会吃掉大约五档。
在平静的市场里,这只是几美分的差别;在新闻前后,它就是交易本身。在西班牙对比利时的四分之一决赛中,最优价位上的挂单薄到约 $1.3k,价差扩大到 31¢,而中间价在 0.65 秒内移动了 64 个点;按中间价成交的回测会记下一笔任何订单都不可能拿到的利润。这篇研究笔记的链接放在文末。
一次下载覆盖一个市场(一个系列,如 “Bitcoin Up or Down 4h”,或一个独立事件)的一个 UTC 日,格式为 parquet,并附带一份 CSV 版本。按投入程度从低到高,有三种获取方式:
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)
覆盖范围:订单簿与成交自 2026-05-11 起实时采集,订单簿历史最早至 2026 年 2 月,链上成交最早至 2023-08,按市场而定,2026 年以前较稀疏。
每个文件都带有 ts_recv_ns,即采集器的纳秒级接收时间,排序以它为准;ts_src_ms 是平台自身的毫秒时间戳。行以 token_id(每个结果一个,所以一个市场有 YES 和 NO 两侧)和 condition_id(即市场)为键。参考表可以把这两者映射为问题文本。
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)
完整字段说明见 schema 文档(英文);基于同一数据包运行过的 notebook 见 /quickstart.html。
l2.parquet 在每次订单簿更新时保存一份完整快照:ask_px_1..25 和 ask_sz_1..25(买方同理),超出现有最深一档的位置以 NaN 填充(这些档位在真实的一天里挂着多少量,见 Polymarket 订单簿数据)。于是为市价买单定价就只是算术:从每一档依次取用美元金额,直到订单成交完毕,再做除法。下面的脚本对当天每一个快照、按三种订单规模做这个计算,得到的就是你的策略当时实际会面对的执行成本分布。
# 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}¢")
| 市价买入 | 可成交快照 | 相对中间价 · 中位数 | 相对中间价 · p90 | 相对最优卖价 · 中位数 | 相对最优卖价 · 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¢ |
“Bitcoin Up or Down - August 19, 4:00PM-8:00PM ET” 的 YES 侧 · 93,016 个订单簿快照 · 2026-08-19 UTC · 滑点以概率美分计,即平均成交价减去参考价。
把它和大多数回测的假设对照来看。在一份价格接近 50¢ 的合约上,按中间价成交的模型有一半时间会把一笔 $2,000 买单的价格低估约 5¢,每十个快照中就有一个低估超过 7¢。一旦订单大于最优价位上的挂单量,即便“按最优卖价成交”也过于乐观:$2,000 买单的中位数会越过最优卖价 3.8¢。还有 1% 的时间,可见订单簿根本吃不下 $2,000。这些在价格图上都看不到,而且会在策略的整个生命周期里不断累积。
2026-07-10 的世界杯四分之一决赛:梅里诺在第 89 分钟进球,“Will Spain win” 从 26¢ 涨到 90¢,其中 80% 的涨幅发生在 0.65 秒内,价差扩大到 31¢,最优价位上的挂单降到约 $1.3k。第一笔主动成交比订单簿本身明显重新定价还早了 35 毫秒。按价格序列成交的回测会记下一个任何订单都拿不到的入场价;价格档位才告诉你当时实际能成交到什么。
交易的是 Hyperliquid?同样的逐档吃单方法,应用在其 25 档订单簿上,并按成交记录计入吃单手续费,见 如何回测 Hyperliquid。
样本只是比特币市场的一天。免费账户可以从平台上的全部市场中自选 5 个市场·日,数据结构完全相同,这个脚本无需修改就能在其中任何一个上运行。