# Polymarket 刷量爭議：Data API 與撮合日誌裡的成交量陷阱

> Canonical: https://vxi.me/zh-tw/posts/polymarket-election-volume-wash-trading-api-price-discovery/
> Locale: zh-tw
> Published: 2026-10-21
> Updated: 2026-10-21
> Markdown: https://vxi.me/zh-tw/posts/polymarket-election-volume-wash-trading-api-price-discovery.md

[WIRED 在 2026 年 10 月 15 日的調查](https://www.wired.com/story/over-dollar600-million-traded-on-polymarket-election-contracts-after-races-ended/)把兩場選舉市場推上檯面：報導引述 MSR Decode 的分析，稱衣索比亞市場約 2.84 億美元成交來自成對帳戶反覆買賣；Polymarket 回應已調查並禁止相關帳戶交易。

這項指控值得查，而且依據是交易對手之間的循環，不只是「落選候選人的成交額很大」。但自己打開 API 驗證時，也很容易先把資料算錯：候選人名字下面的成交額，是否都在押他贏？一個雜湊出現三次，是否就是三次重複交易？

我核對了兩場事件的累計金額、兩個局部成交窗口，以及三筆 Polygon 撮合交易。高成交量提示了調查方向，真正要拆的是交易結構。在這兩個小窗口裡，超過 98% 的名義成交金額來自 **No 代幣的買入與賣出合計**，不是「98% 的資金都在買他輸」。接近 $1 的 No 合約同樣可以反覆換手；正常交易與人為循環都可能出現這種分布，差別要到交易對手與資金流裡找。

只看爭議與數字口徑，可以先讀金額對照；需要自己寫擷取器或索引器，再展開 Python 範例與撮合日誌規則。

## event、condition、token 與累計金額

在 Polymarket，一場選舉對應的 event 可以包含多個候選人子市場。每個子市場有自己的 condition，以及 Yes、No 兩個結果代幣；同一個結果代幣還可以被買入或賣出。

```text
event：整場選舉事件
  └─ condition：某位候選人是否獲勝
       ├─ Yes token → BUY / SELL
       └─ No token  → BUY / SELL
```

我留存的 Gamma API 快照裡，兩場事件的資料如下。各自的子市場 `volume` 加總與事件總量一致。

| 官方 Gamma 事件 | 子市場數 | 累計回報成交額 |
| --- | ---: | ---: |
| [Next Prime Minister of Ethiopia](https://gamma-api.polymarket.com/events/slug/next-prime-minister-of-ethiopia) | 33 | $286,138,162.70 |
| [(Old) Romania Election](https://gamma-api.polymarket.com/events/slug/romania-presidential-election) | 6 | $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 公開研究](https://msrdecode.com/cases/artificial-volume-census.html)與對應的 [JSON 資料表](https://msrdecode.com/artificial-volume-census.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 契約](https://data-api.polymarket.com/v2/openapi.json)規定，`GET /v2/trades` 用 `condition` 篩選合約，清單位於頂層 `data`，每筆 `Trade` 包含 `condition_id`；翻頁使用 `pagination.next_cursor`。`taker_only=true` 回傳 taker 一側紀錄。以 condition 查詢時，API 忽略 `start/end`，時間窗口需要在取回資料後自行篩選。

**不帶游標的 `limit=250` 請求，取得的是當下最新一頁，不會鎖定我保存的歷史快照。** 新紀錄或歷史資料修正，都可能改變這一頁涵蓋的時間。下方程式只在目前頁面跨過兩個時間邊界時加總；83 筆及對應金額是留存快照的結果，不是任何時候執行都會得到的保證。

<details>
<summary>展開 Python 窗口檢查範例</summary>

另存為 `window_check.py`，執行 `python window_check.py`。程式請求目前頁面，檢查合約、排序、金額與時間邊界，再決定是否輸出合計。

```python
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` 則表示最新紀錄也早於窗口終點，繼續取舊頁解決不了；需要另行確認停止交易的時間與資料涵蓋範圍，不能刪掉檢查來硬算金額。

這個檢查刻意保守：市場可能早已停止成交，即使完整歷史裡也沒有終點以後的紀錄。確認完整歷史或終止邊界後，可以採用另一套涵蓋證明，但那不是本範例「單頁跨過兩端」的判斷。

</details>

## CTF Exchange V2：先識別日誌，再選擇金額口徑

對 Adanech 子市場另取 `taker_only=false` 的 100 行回應，只得到 49 個不同交易雜湊；48 個雜湊出現多次，最多 3 行。直接算 `100 - 49 = 51`，把剩餘行數稱為重複刷量，混淆了回傳行與鏈上交易。每個雜湊只留第一行，同樣會誤刪一次交易裡的不同紀錄。

[固定版本的 CTF Exchange V2 原始碼](https://github.com/Polymarket/ctf-exchange-v2/blob/ccc0596074f4dfd62c944fbca4de252893b82b4b/src/exchange/mixins/Trading.sol)說明了事件層次：一次 `matchOrders` 處理一個 taker order 與一組 maker orders，每個 maker 成交時發出一條 `OrderFilled`；taker 處理完後，另發一條彙總 `OrderFilled`，緊接著發出 `OrdersMatched`。[`_emitTakerFilledEvents`](https://github.com/Polymarket/ctf-exchange-v2/blob/ccc0596074f4dfd62c944fbca4de252893b82b4b/src/exchange/mixins/Events.sol)就是最後兩條事件的發出位置。

三筆已核對交易保留了以下計數。**這張表核對事件數量，不據此推導兩側金額相等。**

| Polygon 交易 | `OrderFilled` | `OrdersMatched` | 對應事件層次 |
| --- | ---: | ---: | --- |
| [A](https://polygon.blockscout.com/tx/0x3bbc4f9c0363cf8fc1a89031183a6ae2bd9eb972a1d9b59acf87021118e0f5f6) | 2 | 1 | 1 條 maker 明細＋1 條 taker 彙總 |
| [B](https://polygon.blockscout.com/tx/0x243ebb2cddf06e31f85fe3cdcb94a7d839fd029cb4d58e68ede64cbe7a994dc3) | 3 | 1 | 2 條 maker 明細＋1 條 taker 彙總 |
| [C](https://polygon.blockscout.com/tx/0xee9d91888a8109cb284d7c2a9980694738db07534786f985c501ee0b1376bfd8) | 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 的重複。

```text
直接成交範例：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` 不成立，就停止該回執的自動彙整。

```python
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:25 | 0.0030 |
| 20:30 | 0.0020 |
| 20:35 | 0.0025 |
| 21:00 | 0.0030 |

五分鐘資料呈現了粗取樣漏掉的下跌與恢復。它提供的是區間價格，因果價格衝擊仍需當時報價、深度與消息時點。

這次已結算合約的歷史查詢，還在末尾附加了 `resolution_seconds=0、price=0` 的兌付端點。依照[介面約定](https://data-api.polymarket.com/v2/docs)，把它與普通五分鐘點分開，結算狀態另查 [resolutions](https://data-api.polymarket.com/v2/resolutions?condition=0x7d0aaf81bbd3fd73b6a1651cce08a452c0cbf9c0cbb4520ce0f981065b639d88)；混入行情序列，就會憑空畫出一次跌到零的「暴跌」。

只做價格曲線或訊號分析，可以先讀取歷史價格，不必從鏈上逐筆還原 `OrderFilled`。但過濾兌付端點只是清洗的一步：還要檢查時間涵蓋、缺失區間與合約結算；要估計策略可實現的收益，則另需驗證成交假設、費用與滑價。歷史區間價格本身不是可成交報價。

真正能重用的是這個核對順序：**先統一合約、時間與分母，再拆 Yes/No 與 BUY/SELL；日誌身分對齊之後，按匹配類型選擇金額口徑。** 對外部研究也一樣，複算公開表格與重建分類器是兩層工作。保留各層依據，比先爭論一個刷量百分比更有用。類似分母問題也會出現在選股回測：[同一檔股票反覆入選，量化策略勝率還可信嗎？](/zh-tw/posts/quant-winrate-repeated-stock-picks/)用完整 Python 與 SQL 對比三種統計口徑，可以接著複算。

*本文討論資料口徑與工程實作，不構成交易建議。Gamma 金額與局部窗口來自 2026-10-20 UTC 快照；MSR 公開表、API 契約與固定原始碼於 2026-10-21 UTC 複核。*
