▌tickfoundry
实时采集中EN登录获取数据 →
§ 教程

如何回测 Polymarket
基于当时真实存在的订单簿。

python · pandas免费数据TickFoundry · 2026-09-12 · 基于免费样本运行,无需注册

大多数 Polymarket 回测都按最新成交价或中间价成交,因为价格序列能提供的也就只有这些。可在一个 0.65 秒内重新定价 64 个点、价差被拉到 31¢ 的市场上,这个假设本身就决定了整个回测结果。本教程用正确的方法来做:获取历史订单簿,用 pandas 加载,并按那一刻实际挂着的各档价位逐档吃单,为每一笔模拟订单定价。下文每个数字都来自免费样本日和文中展示的脚本,你在注册任何东西之前就能自己复现。

用样本数据跟着做:BTC Up or Down 4h · 2026-08-19 · 全天的 L1 / L2 / 成交。
↓ parquet 数据包(23.0 MB)↓ slippage.py

为什么只有价格序列不够

Polymarket 的订单簿在链下运行。公开 API 只提供采样后的价格,承载订单簿更新的 WebSocket 也不会替你留存,一条更新一旦过去就再也找不回来。只基于价格的回测不得不凭空假设三样东西:成交那一刻的价差、实际挂着的数量,以及报价和成交到达的先后顺序。在样本日最活跃的市场上,时间加权价差为 1.73¢,最优卖价上挂单数量的中位数是 $50。因此一笔 $500 的市价买单永远不可能全部在最优价成交,它会吃掉大约五档。

在平静的市场里,这只是几美分的差别;在新闻前后,它就是交易本身。在西班牙对比利时的四分之一决赛中,最优价位上的挂单薄到约 $1.3k,价差扩大到 31¢,而中间价在 0.65 秒内移动了 64 个点;按中间价成交的回测会记下一笔任何订单都不可能拿到的利润。这篇研究笔记的链接放在文末。

第 1 步 · 获取历史订单簿

一次下载覆盖一个市场(一个系列,如 “Bitcoin Up or Down 4h”,或一个独立事件)的一个 UTC 日,格式为 parquet,并附带一份 CSV 版本。按投入程度从低到高,有三种获取方式:

免费样本,无需注册

一个比特币市场的完整一天:l1、l2、trades、一小时原始数据流以及参考表。

下载 →
5 个免费市场·日

免费账户可选任意市场、2026 年 7 月内的任意一天。只需邮箱和密码,无需绑卡。

选择市场 →
REST API

Explorer 及以上套餐:列出平台上的市场,请求一个市场·日,流式下载数据包。示例代码见下方。

API 文档 →
通过 API 请求一个市场·日python
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)

覆盖范围:订单簿与成交自 2026-05-11 起实时采集,订单簿历史最早至 2026 年 2 月,链上成交最早至 2023-08,按市场而定,2026 年以前较稀疏。

第 2 步 · 用 pandas 加载数据

每个文件都带有 ts_recv_ns,即采集器的纳秒级接收时间,排序以它为准;ts_src_ms 是平台自身的毫秒时间戳。行以 token_id(每个结果一个,所以一个市场有 YES 和 NO 两侧)和 condition_id(即市场)为键。参考表可以把这两者映射为问题文本。

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)

完整字段说明见 schema 文档(英文);基于同一数据包运行过的 notebook 见 /quickstart.html。

第 3 步 · 逐档吃单模拟成交

l2.parquet 在每次订单簿更新时保存一份完整快照:ask_px_1..25 和 ask_sz_1..25(买方同理),超出现有最深一档的位置以 NaN 填充(这些档位在真实的一天里挂着多少量,见 Polymarket 订单簿数据)。于是为市价买单定价就只是算术:从每一档依次取用美元金额,直到订单成交完毕,再做除法。下面的脚本对当天每一个快照、按三种订单规模做这个计算,得到的就是你的策略当时实际会面对的执行成本分布。

slippage.py · 下表数据的生成脚本python
# 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}¢")
市价买入可成交快照相对中间价 · 中位数相对中间价 · p90相对最优卖价 · 中位数相对最优卖价 · 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¢

“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。这些在价格图上都看不到,而且会在策略的整个生命周期里不断累积。

第 4 步 · 让回测真实可信的规则

每笔订单都用其接收时刻生效的快照定价
用 ts_recv_ns 排序,不要用 ts_src_ms;找到决策时刻或之前的最后一行 l2,按上文的方法逐档吃单。
YES 和 NO 是同一个订单簿
NO 订单簿就是 YES 订单簿按 1 − 价格镜像而来,逐档对应、数量相同(在样本最活跃的四个小时里每一秒都成立),因此以 7¢ 买入 NO 与以 93¢ 卖出 YES 消耗的是同一份流动性。只对一侧逐档吃单,绝不要把另一侧当作额外深度叠加进去。
用挂单深度限制订单规模
如果价格档位吃不下整笔订单,剩余部分就不会成交。上表的“可成交快照”一列显示了即使在流动性充足的一天,这种情况出现得有多频繁。
把信号与执行分开
1 分钟 K 线(每个数据包都附带)用来生成信号没有问题,执行则需要订单簿。两者一混用,按中间价成交的乐观偏差就会悄悄回来。
核对结算依据
reference/markets.csv 包含市场描述,其中有加密货币系列所用的 Chainlink TWAP 规则;你的标签必须与市场实际的结算方式一致。
成交记录的手续费列一律为 0
WebSocket 成交记录中的 fee_rate_bps 记为 0;收取手续费的市场需要在模型中套用平台的费率表。

按中间价成交的假设在哪里错得最离谱

2026-07-10 的世界杯四分之一决赛:梅里诺在第 89 分钟进球,“Will Spain win” 从 26¢ 涨到 90¢,其中 80% 的涨幅发生在 0.65 秒内,价差扩大到 31¢,最优价位上的挂单降到约 $1.3k。第一笔主动成交比订单簿本身明显重新定价还早了 35 毫秒。按价格序列成交的回测会记下一个任何订单都拿不到的入场价;价格档位才告诉你当时实际能成交到什么。

阅读西班牙对比利时研究笔记(英文)→从订单簿看世界杯决赛(英文)

交易的是 Hyperliquid?同样的逐档吃单方法,应用在其 25 档订单簿上,并按成交记录计入吃单手续费,见 如何回测 Hyperliquid。

常见问题

Polymarket 提供历史订单簿数据吗?
不提供。Polymarket 的订单簿在链下运行,公开接口只提供采样后的价格点,而不是订单簿快照;实时 WebSocket 也不会替你留存,所以深度数据无法事后补取。只有当时有人在记录的数据才存在。TickFoundry 自 2026-05-11 起持续采集整个平台,订单簿历史最早可追溯至 2026 年 2 月。
可以免费回测 Polymarket 吗?
可以。样本数据包是一个比特币市场完整的一个 UTC 日(L1、25 档 L2、成交、一小时原始数据流以及参考表),无需注册即可下载。注册免费账户后,还可以从数据目录中自选 5 个市场·日。无需绑卡。
除了价格序列,Polymarket 回测还需要什么?
价格图给不了你的三样东西:你本应下单那一刻的价差、每一档挂着的数量(由此才能算出市价单的真实成交均价),以及报价和成交到达的先后顺序。这三样都在 L2 和成交文件里,并带有纳秒级接收时间戳。
哪些 Python 工具可以处理这些数据?
数据包为 parquet 格式并附带一份 CSV 版本,pandas、polars、DuckDB 和 Arrow 都能直接读取;本页教程只用到 pandas 和 numpy。hftbacktest 和 NautilusTrader 的适配器已在计划中;在此之前,可以用 l2 各列为每个引擎构造它所需的快照或增量输入。
成交和手续费是如何记录的?
trades.parquet 是平台自身广播的成交记录原样:价格、数量(份额)、主动方方向和交易哈希。成交记录中的 fee_rate_bps 列记为 0;如果你的市场收取手续费,请在模型中套用平台的费率表。链上结算成交(最早至 2023 年)是单独的数据层,Premium 及以上套餐提供。
在你真正交易的市场上跑一遍。

样本只是比特币市场的一天。免费账户可以从平台上的全部市场中自选 5 个市场·日,数据结构完全相同,这个脚本无需修改就能在其中任何一个上运行。

选择你的免费数据集 →覆盖整个平台的套餐