Polymarket 刷量争议:Data API 与撮合日志里的成交量陷阱
WIRED 在 2026 年 10 月 15 日的调查把两场选举市场推到了台前:报道引述 MSR Decode 的分析,称埃塞俄比亚市场约 2.84 亿美元成交来自成对账户的反复买卖;Polymarket 回应称已调查并封禁相关账户。
这个指控值得查,而且它依据的是交易对手之间的循环,不只是“落选候选人的成交额很大”。但自己打开 API 验证时,也很容易先把数据算错:候选人名字下面的成交额是否都在押他赢?一个哈希出现三次,是否就是三次重复交易?
我核对了两场事件的累计金额、两个局部成交窗口,以及三笔 Polygon 撮合交易。高成交量提示了调查方向,真正要拆的是交易结构。在这两个小窗口里,超过 98% 的名义成交金额来自 No 代币的买入与卖出合计,不是“98% 的资金都在买他输”。接近 $1 的 No 合约同样可以反复换手;正常交易与人为循环都可能出现这种分布,区别要到交易对手和资金流里找。
只看争议与数字口径,可以先读金额对照;需要自己写采集器或索引器,再展开 Python 示例和撮合日志规则。
event、condition、token 与累计金额
在 Polymarket,一场选举对应的 event 可以包含多个候选人子市场。每个子市场有自己的 condition,以及 Yes、No 两个结果代币;同一个结果代币还可以被买入或卖出。
event:整场选举事件
└─ condition:某位候选人是否获胜
├─ Yes token → BUY / SELL
└─ No token → BUY / SELL
我留存的 Gamma API 快照里,两场事件的数据如下。各自的子市场 volume 求和与事件总量一致。
| 官方 Gamma 事件 | 子市场数 | 累计报告成交额 |
|---|---|---|
| Next Prime Minister of Ethiopia | 33 | $286,138,162.70 |
| (Old) Romania Election | 6 | $371,837,992.48 |
| 合计 | 39 | $657,976,155.18 |
这里的 6.58 亿美元是接口快照中的全历史累计量。WIRED 标题强调的是选举结果已定之后的交易,而我尚未按两场事件各自的结果明确时间切分这些累计额。两者在这里仅作规模对照,这张表不承担复现“赛后成交额”的任务。
MSR 的 2.84 亿,分母是什么?
把报道中取整的 $284,000,000 除以 Gamma 的 $286,138,162.70,会得到约 99.25%。算式本身没问题,问题在于分子、分母来自不同的统计范围。
MSR 公开研究和对应的 JSON 数据表给出了自己的分母。研究覆盖 2026-04-28 至 09-09,Polygon 区块范围为 86107336–93524955。其中 event_id=411239 的七条埃塞俄比亚子市场记录,分别列出 turnover_usd 和筛查命中的 recycled_usd。这七行相加得到:
| 来源与统计范围 | 数值 | 计量对象 |
|---|---|---|
| MSR 公开表:7 个子市场、研究区块范围内 | $283,964,639.89 | recycled_usd 合计 |
| 同一张表、同样 7 个子市场和区块范围 | $285,027,224.40 | 对应的 turnover_usd 分母 |
| Gamma 快照:事件下全部 33 个子市场 | $286,138,162.70 | 累计报告成交额 |
同表相除是 $283,964,639.89 / $285,027,224.40 ≈ 99.63%,与 MSR 页面保留一位小数的 99.6% 一致。这个高比例正是其七个子市场、该研究区间内的结果。
要与 Gamma 接成同一笔账,还得统一七个 condition_id、起止区块和经济成交计数规则。MSR 的公开规则把直接份额交易按现金价格计,成对创建或合并按每对 $1 计,每个经济 fill 计一次。不同路径需要各自对应的金额定义,不能一律套成 API 某一侧的 size × price。
本次复算做到的是公开表加总。MSR 尚未公开具体的循环片段(episode)判定阈值,公开 census 与 WIRED 委托分析是否使用同一冻结版本也未确认。独立重建分类还缺这些信息;目前可以核对“约 2.84 亿”的公开出处和分母。
两个小窗口里的 No 成交
Adanech Abiebie 子市场累计约 8,346 万美元,Nicolae Ciucă 子市场约 3.265 亿美元,后者占罗马尼亚事件总量约 87.8%。名字很显眼,实际成交对象还要拆到 Yes/No 代币。
我分别读取两个子市场最近 250 条 taker 记录,选出被返回数据起止时间完整覆盖的 UTC 窗口:
| 子市场与 UTC 窗口 | 记录数 | size × price 合计 | No 代币金额(BUY+SELL) |
|---|---|---|---|
| Adanech,2026-09-01 至 09-30,不含终点 | 83 | $2,997.93 | $2,987.15,99.64% |
| Ciucă,2025-04-30 全天 | 128 | $95,930.59 | $94,609.25,98.62% |
outcome=No, side=BUY 是买入 No;outcome=No, side=SELL 是卖出 No。上表两者都计入成交金额。若要判断净买入或持仓变化,还需按账户、份额和买卖方向另算。
两段窗口合计约 9.89 万美元,只占这两个子市场累计金额的 0.0241%,并且不是随机抽样。选它们是因为一页记录能完整覆盖窗口,便于检查字段和算式;其用途是演示口径,而不是描绘两个市场的整个历史。
Data API v2:当前一页不等于历史快照
官方 OpenAPI 契约规定,GET /v2/trades 用 condition 筛选合约,列表位于顶层 data,每条 Trade 包含 condition_id;翻页使用 pagination.next_cursor。taker_only=true 返回 taker 一侧记录。按 condition 查询时,接口忽略 start/end,时间窗口需要在取回数据后自行过滤。
不带游标的 limit=250 请求拿的是当前最新一页,不会锁定我保存的历史快照。 新记录或历史数据修正都可能改变这一页覆盖的时间。下面的脚本只在当前页跨过两个时间边界时汇总;83 条及对应金额是留存快照的结果,不是任意时刻运行脚本的承诺。
展开 Python 窗口检查示例
保存为 window_check.py,运行 python window_check.py。程序会请求当前页,检查合约、排序、金额和时间边界,再决定是否输出汇总。
import json
from datetime import datetime, timezone
from decimal import Decimal
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
CONDITION = '0x7d0aaf81bbd3fd73b6a1651cce08a452c0cbf9c0cbb4520ce0f981065b639d88'
START = int(datetime(2026, 9, 1, tzinfo=timezone.utc).timestamp())
END = int(datetime(2026, 9, 30, tzinfo=timezone.utc).timestamp())
def summarize(payload):
rows = payload.get('data')
if not isinstance(rows, list) or not rows:
raise ValueError('Missing or empty v2 data list')
if any(r['condition_id'] != CONDITION for r in rows):
raise ValueError('Unexpected condition_id')
times = [int(r['timestamp']) for r in rows]
if times != sorted(times, reverse=True):
raise ValueError('Unexpected trade ordering')
if times[0] < END:
raise ValueError('Upper bound unproven: older pages cannot fix this')
if times[-1] >= START:
raise ValueError('Window not bracketed: follow pagination.next_cursor')
sample = [r for r in rows if START <= int(r['timestamp']) < END]
amounts = {'Yes': Decimal('0'), 'No': Decimal('0')}
for r in sample:
size, price = Decimal(str(r['size'])), Decimal(str(r['price']))
if not size.is_finite() or not price.is_finite() or size < 0 or not 0 <= price <= 1:
raise ValueError('Invalid size or price')
if r['outcome'] not in amounts:
raise ValueError('Unlabelled outcome: inspect before aggregation')
amounts[r['outcome']] += size * price
return len(sample), sum(amounts.values()), amounts['No']
def build_request():
params = urlencode({'condition': CONDITION, 'limit': 250, 'taker_only': 'true'})
return Request('https://data-api.polymarket.com/v2/trades?' + params,
headers={'Accept': 'application/json',
'User-Agent': 'PolymarketVolumeCheck/1.0'})
if __name__ == '__main__':
try:
with urlopen(build_request(), timeout=20) as response:
payload = json.load(response, parse_float=Decimal)
except HTTPError as error:
raise SystemExit(f'HTTP {error.code}: stop; check API access or Retry-After')
except URLError as error:
raise SystemExit(f'Network error: {error.reason}')
count, total, no_total = summarize(payload)
print('rows:', count)
print('all:', total.quantize(Decimal('0.01')))
print('No:', no_total.quantize(Decimal('0.01')))
请求显式标识了脚本的 User-Agent,但一个请求头不能保证所有网络环境都获得放行。若返回 403,应检查访问条件;429/503 则结合服务返回的 Retry-After 处理,不要把网络失败误当成空数据。
Window not bracketed 表示当前页还不够旧:沿 pagination.next_cursor 取更早的页面,并始终保留同一组过滤参数。这段示例没有自动翻页。另一种 Upper bound unproven 表示最新记录也早于窗口终点,继续取旧页解决不了它;需要另行确认市场停盘时间与数据覆盖,不能删掉检查来强行打印金额。
这个检查刻意保守:市场可能早已停止成交,完整历史里本来就没有终点以后的记录。确认完整历史或终止边界后可以采用另一套覆盖证明,但那不是本示例“单页跨过两端”的判断。
CTF Exchange V2:先识别日志,再选择金额口径
对 Adanech 子市场另取 taker_only=false 的 100 行响应,只得到 49 个不同交易哈希;48 个哈希出现多次,最多 3 行。直接算 100 - 49 = 51,把剩余行数叫作重复刷量,混淆了返回行与链上交易。按哈希只留第一行,同样会误删一次交易里的不同记录。
固定版本的 CTF Exchange V2 源码说明了事件层次:一次 matchOrders 处理一个 taker order 和一组 maker orders,每个 maker 成交时发出一条 OrderFilled;taker 处理完后,另发一条汇总 OrderFilled,紧接着发出 OrdersMatched。_emitTakerFilledEvents就是最后两条事件的发出位置。
三笔已核对交易保留了如下计数。这张表核对事件数量,不据此推导两侧金额相等。
| Polygon 交易 | OrderFilled | OrdersMatched | 对应的事件层次 |
|---|---|---|---|
| A | 2 | 1 | 1 条 maker 明细 + 1 条 taker 汇总 |
| B | 3 | 1 | 2 条 maker 明细 + 1 条 taker 汇总 |
| C | 2 | 1 | 1 条 maker 明细 + 1 条 taker 汇总 |
这些是订单记录数,同一钱包可以有多个订单。taker 汇总那条 OrderFilled.maker 填的也是 takerOrder.maker,即主动订单的创建者;事件字段名 maker 本身不是被动成交角色的判定器。
同代币直接成交与 Yes/No 配对,不是一张金额账
先用一个同一结果代币、BUY 对 SELL 的构造示例解释汇总重复:以每份 $0.60 买入 10 份,分别吃掉 4 份和 6 份卖单。两条 maker 明细各是 $2.40、$3.60,taker 对应总额是 $6。把三条都当成独立成交金额才会变成 $12。这个例子不是 A/B/C 的金额还原。
跨 Yes/No 的 MINT/MERGE 路径要另算。以无费用、无余量的 MINT 构造例子为例,一方为 10 份 Yes 付 $4,另一方为 10 份 No 付 $6,两方共同形成 $10 抵押品。这次 $4 与 $6 是不同结果代币对应的支付,不能因为其中一条属于 taker 汇总,就把 $6 当作 $4 的重复。
直接成交示例:maker 明细 2.40 + 3.60 = taker 对应总额 6.00
MINT 示例: Yes 支付 4.00 + No 支付 6.00 = 抵押品 10.00
在后一类纯 MINT、计量精度对齐且无费用或余量的简化关系里,taker 的抵押品支付等于“配对总抵押品减 maker 方支付”;收到的结果份额按另一侧汇总。真实代码还须识别 side、tokenId、抵押品精度、费用和余量。尤其注意,固定源码的枚举名 COMPLEMENTARY 指同代币 BUY/SELL 的直接匹配,跨互补结果代币的双买、双卖则走 MINT、MERGE,不要被英文名字误导。
Maker 明细和 taker 汇总分别留表。 日志分类用于识别记录的角色;经济成交额要在明确匹配类型之后定义。这也解释了为什么事件计数正确,金额算法仍可能错误。
汇总事件的身份判定
原始事件表用 (chain_id, transaction_hash, log_index) 去掉重复抓取。这里采用 RPC 的区块级 logIndex,按该值建立查找映射,不把它直接当作 receipt.logs 的数组下标。若索引库使用交易内序号,应统一改用那套序号,保持原始日志顺序和紧邻关系。
下面仍是判定伪代码,适用于同一完整成功回执、已确认使用上述 V2 路径的发出合约。ABI 解码和地址、整数表示须先统一;任何 require 不成立,就停止该回执的自动聚合。
for m in events_of(receipt, 'OrdersMatched'):
f = log_at(receipt, m.log_index - 1)
require(f.event == 'OrderFilled' and f.address == m.address)
require((f.orderHash, f.maker, f.side, f.tokenId,
f.makerAmountFilled, f.takerAmountFilled) ==
(m.takerOrderHash, m.takerOrderMaker, m.side, m.tokenId,
m.makerAmountFilled, m.takerAmountFilled))
taker_summary_ids.add(raw_id(f))
maker_fills = [f for f in order_fills if raw_id(f) not in taker_summary_ids]
events_of 与 order_fills 只包含当前已确认的合约。最后一行执行前,全部相关 OrderFilled 必须处在完整、可解释的撮合组内;缺日志、字段冲突或未闭合组应暂存待查。一个交易可以有多次撮合,每组分别匹配。未来版本若打破紧邻关系,就暂停该回执的自动聚合并重新核对版本,不能只凭 orderHash 找全交易最后一条来兜底:同一订单可能跨调用部分成交。分出的两张表保留各自金额,再按直接成交、MINT 或 MERGE 口径计算。
快速回转筛查,最容易被忽略的是阈值
我对两个窗口用了同一条规则:按同一代理钱包标识、同一结果代币分组,寻找数量差不超过 2% 的反向买卖;每条记录最多配对一次。只改变允许的时间间隔,结果就很不一样。
| 配对时间上限 | Adanech,83 条窗口记录 | Ciucă,128 条窗口记录 |
|---|---|---|
| 30 秒 | 1 对 | 2 对 |
| 60 秒 | 2 对 | 37 对 |
| 5 分钟 | 2 对 | 39 对 |
| 15 分钟 | 19 对 | 39 对 |
| 60 分钟 | 20 对 | 44 对 |
15 分钟规则选中的记录金额分别占窗口总额的 8.39% 和 2.50%。它们是待核查候选,而不是已定性的刷量。
一个构造的反例就能说明区别:做市账户买入 100 份 No,价格 $0.985;9 秒后以 $0.986 卖出同样数量,扣费前价差 $0.10。正常库存调整和刻意制造量的循环,都可能满足这套短时、等量、反向条件。
下一步需要补的是对手关系、跨钱包资金联系、手续费与库存变化。这套同钱包回转筛查,与 WIRED 引述的“成对账户反复互相交易”分析使用了不同的识别条件。
五分钟价格改变了最初的判断
Adanech 的 Yes 合约在 2026 年 9 月 22 日,几个相邻的十二小时观察点都是 0.0030。最初只能看到“成交还在发生,粗采样价格没变”;换成五分钟数据,这个判断就不完整了。
完整序列含 288 个正常五分钟区间。逐点比较检出三次价格变化;下表只列变化前基准和三个变化起点,省略了价格未变的区间,因此四行并非连续采样点。
| UTC 时刻 | Yes 历史区间价格 |
|---|---|
| 20:25 | 0.0030 |
| 20:30 | 0.0020 |
| 20:35 | 0.0025 |
| 21:00 | 0.0030 |
五分钟数据呈现了粗采样漏掉的下跌与恢复。它提供的是区间价格,因果价格冲击仍需当时的报价、深度和消息时点。
这次已结算合约的历史查询还在末尾附加了 resolution_seconds=0、price=0 的兑付端点。依照接口约定,把它与普通五分钟点分开,结算状态另查 resolutions;混入行情序列,就会凭空画出一次跌到零的“暴跌”。
只做价格曲线或信号分析,可以先读取历史价格,不必从链上逐笔还原 OrderFilled。但过滤兑付端点只是清洗的一步:还要检查时间覆盖、缺失区间和合约结算;要估计策略可实现的收益,则另需验证成交假设、费用与滑点。历史区间价格本身不是可成交报价。
真正能复用的,是这条核对顺序:先统一合约、时间和分母,再拆 Yes/No 与 BUY/SELL;日志身份对齐之后,按匹配类型选择金额口径。 对外部研究也一样,复算公开表格与重建分类器是两层工作。把每层依据留清楚,比先争论一个刷量百分比更有用。类似的分母问题也会出现在选股回测里:同一只股票反复入选,量化策略胜率还可信吗?用完整 Python 和 SQL 对比了三种统计口径,可以接着复算。
本文讨论数据口径与工程实现,不构成交易建议。Gamma 金额和局部窗口来自 2026-10-20 UTC 快照;MSR 公开表、API 契约与固定源码于 2026-10-21 UTC 复核。