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 億美元是 API 快照中的全歷史累計量。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 查詢時,API 忽略 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%。它們是待核查候選,而不是已定性的刷量。

一個構造反例就能說明差別:造市帳戶以 $0.985 買入 100 份 No,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 複核。