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 Ethiopia33$286,138,162.70
(Old) Romania Election6$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.89recycled_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 交易OrderFilledOrdersMatched对应的事件层次
A211 条 maker 明细 + 1 条 taker 汇总
B312 条 maker 明细 + 1 条 taker 汇总
C211 条 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:250.0030
20:300.0020
20:350.0025
21:000.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 复核。