# Polymarketの水増し疑惑：Data APIと約定ログに潜む出来高集計の落とし穴

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

[2026年10月15日のWIREDの調査記事](https://www.wired.com/story/over-dollar600-million-traded-on-polymarket-election-contracts-after-races-ended/)は、二つの選挙市場を取り上げました。MSR Decodeの分析を引用し、エチオピア市場の約2億8400万ドルの取引が、ペアになった口座間での反復売買によるものだと報じています。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億5800万ドルは、APIスナップショットの全期間累計**です。WIREDの見出しが扱うのは選挙結果が決まった後の取引ですが、この累計額を、各選挙の結果が明確になった時刻で分割してはいません。ここでは規模の比較にとどめ、「結果判明後の出来高」を再現した表とは扱いません。

## MSRの2億8400万ドルは、何を分母にするのか

報道で丸められた$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が小数第1位まで示した99.6%と一致します。この高い割合は、その七市場、その研究期間の結果です。

Gammaと同じ勘定にするには、七つの`condition_id`、開始・終了ブロック、経済的な約定の数え方を合わせる必要があります。MSRの公開ルールでは、直接の持分取引は現金価格、ペアの作成・統合は1ペアあたり1ドルとし、経済的なfillを一度ずつ数えます。経路ごとに金額の定義が違うため、すべてをAPI片側の`size × price`に置き換えることはできません。

今回再計算したのは公開表の合計です。MSRは循環のエピソードを判定する具体的なしきい値を公開しておらず、公開censusとWIRED向け受託分析が同一の固定データ版かどうかも確認できていません。分類を独立再現するにはこの情報が不足していますが、「約2億8400万ドル」の公開出典と分母は確認できます。

## 二つの小さな期間でのNo取引

Adanech Abiebie市場は累計約8346万ドル、Nicolae Ciucă市場は約3億2650万ドルで、後者はルーマニアイベント全体の約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検索では`start/end`が無視されるため、時間のフィルタリングは取得後に行います。

**カーソルなしの`limit=250`は現在の最新ページを取得するだけで、保存した歴史スナップショットを固定しません。** 記録の追加や訂正によって、ページが覆う時間は変わります。以下のスクリプトは現在のページが両方の時間境界をまたぐ場合だけ集計します。83件と対応額は保存時の結果であり、いつ実行しても同じ出力になる保証ではありません。

<details>
<summary>Pythonの期間検査例を開く</summary>

`window_check.py`として保存し、`python window_check.py`で実行します。現在のページを取得し、condition、並び順、金額、時間境界を検査した上で、合計を出力するか判断します。

```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 | maker明細1件＋taker集約1件 |
| [B](https://polygon.blockscout.com/tx/0x243ebb2cddf06e31f85fe3cdcb94a7d839fd029cb4d58e68ede64cbe7a994dc3) | 3 | 1 | maker明細2件＋taker集約1件 |
| [C](https://polygon.blockscout.com/tx/0xee9d91888a8109cb284d7c2a9980694738db07534786f985c501ee0b1376bfd8) | 2 | 1 | maker明細1件＋taker集約1件 |

これは注文記録数であり、一ウォレットが複数注文を持つこともあります。taker集約イベントの`OrderFilled.maker`にも、積極側注文の作成者である`takerOrder.maker`が入ります。フィールド名`maker`だけでは受動側の明細かどうか判断できません。

### 同一トークンの直接売買とYes/Noのペア処理は別の勘定

まず**同じ結果トークンのBUY対SELLという構成例**で、集約の二重計上を考えます。1口$0.60で10口を購入し、4口と6口の売り注文を約定させたとします。maker明細は$2.40と$3.60、taker側の総額は$6です。三つを独立の経済的金額として足すと$12になります。これはA/B/Cの実際の金額の復元ではありません。

Yes/NoをまたぐMINT/MERGEは別の計算です。**手数料も残余もないMINTの構成例**なら、一方がYes10口に$4、もう一方がNo10口に$6を払い、合計$10の担保を作ります。異なる結果トークンへの支払いなので、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)`で除きます。ここでの`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%**です。調査候補であって、確定した仮装売買ではありません。

構成した反例で違いが分かります。マーケットメイカーがNo100口を$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`の償還端点も追加されていました。[APIの規定](https://data-api.polymarket.com/v2/docs)に従い通常の五分点と分離し、決済状態は[resolutions](https://data-api.polymarket.com/v2/resolutions?condition=0x7d0aaf81bbd3fd73b6a1651cce08a452c0cbf9c0cbb4520ce0f981065b639d88)で確認します。価格系列に混ぜれば、存在しなかったゼロへの暴落を描いてしまいます。

価格曲線やシグナル分析だけなら、まず履歴価格を使い、すべての`OrderFilled`をオンチェーンから復元する必要はありません。しかし償還端点の除去はクリーニングの一段階です。期間カバレッジ、欠損区間、決済を確認し、戦略の実現可能収益まで推定するなら約定仮定、手数料、スリッページも検証します。履歴区間価格は、その価格で取引できる気配値ではありません。

再利用できる確認順序は、**合約・時間・分母をそろえ、Yes/NoとBUY/SELLを分け、ログを識別してからマッチタイプ別に金額を定義する**ことです。外部研究でも、公開表の再計算と分類器の再構築は別作業です。先に割合で議論するより、各層の根拠を残す方が有用です。株式バックテストにも似た分母問題があります。[同じ銘柄が繰り返し選ばれると、量化戦略の勝率は信頼できるか？](/ja/posts/quant-winrate-repeated-stock-picks/)では、完全なPythonとSQLで三つの集計定義を比較しています。

*本稿はデータ定義と実装の議論であり、取引助言ではありません。Gamma金額と局所期間は2026-10-20 UTCのスナップショット、MSR公開表・API契約・固定ソースは2026-10-21 UTCに確認したものです。*
