Polymarket 워시 트레이딩 논란: Data API와 체결 로그의 거래량 함정
2026년 10월 15일 WIRED의 조사 기사는 두 선거 시장을 다뤘습니다. MSR Decode의 분석을 인용해 에티오피아 시장에서 약 2억 8,400만 달러의 거래가 짝을 이룬 계정들의 반복 매매에서 나왔다고 보도했습니다. Polymarket은 조사 후 해당 계정의 거래를 차단했다고 답했습니다.
조사할 가치가 있는 주장입니다. 근거도 단순히 “낙선 후보의 거래액이 크다”가 아니라 거래 상대방 사이의 순환입니다. 하지만 직접 API를 열어 확인할 때도 첫 집계부터 틀릴 수 있습니다. 후보 이름 아래 표시된 금액은 모두 그 후보의 승리에 건 돈일까요? 같은 해시가 세 번 나오면 중복 거래 세 건일까요?
저는 두 이벤트의 누적 금액, 두 개의 작은 거래 구간, Polygon 체결 트랜잭션 세 건을 확인했습니다. 큰 거래량은 조사 방향을 알려 주지만, 실제로 분해해야 할 것은 거래 구조입니다. 두 작은 구간에서 명목 거래 금액의 98% 이상은 No 토큰 매수와 매도의 합계였습니다. “자금의 98%가 패배에 베팅하는 No를 샀다”는 뜻이 아닙니다. 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억 5,800만 달러는 API 스냅샷의 전체 기간 누적값입니다. WIRED 제목은 선거 결과가 정해진 뒤의 거래를 강조합니다. 저는 각 결과가 명확해진 시각을 기준으로 이 누적액을 나누지 않았습니다. 따라서 규모 비교일 뿐, 이 표가 “결과 확정 후 거래액”을 재현한 것은 아닙니다.
MSR의 2억 8,400만 달러, 분모는 무엇인가
보도의 반올림된 $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은 순환 에피소드를 판정하는 구체적 임계값을 공개하지 않았고, 공개 census와 WIRED 의뢰 분석이 동일한 고정 데이터 버전인지도 확인되지 않았습니다. 분류를 독립적으로 재현하려면 이 정보가 더 필요합니다. 다만 “약 2억 8,400만 달러”의 공개 출처와 분모는 대조할 수 있습니다.
두 작은 구간의 No 거래
Adanech Abiebie 시장의 누적액은 약 8,346만 달러, Nicolae Ciucă 시장은 약 3억 2,650만 달러입니다. 후자는 루마니아 이벤트 전체의 약 87.8%입니다. 후보 이름은 눈에 띄지만, 실제 거래 자산은 Yes와 No로 구분해야 합니다.
각 시장의 최근 taker 기록 250개를 읽고, 반환된 첫 시각과 마지막 시각이 양쪽 경계를 모두 감싸는 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만 8,900달러는 두 하위 시장 누적액의 **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 | maker 상세 1개+taker 합계 1개 |
| B | 3 | 1 | maker 상세 2개+taker 합계 1개 |
| C | 2 | 1 | maker 상세 1개+taker 합계 1개 |
이는 주문 기록 수입니다. 같은 지갑이 여러 주문을 가질 수 있습니다. 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 구성 예제에서 한쪽이 Yes 10개에 $4, 다른 쪽이 No 10개에 $6를 지불하면 총 $10의 담보가 만들어집니다. 서로 다른 결과 토큰에 대한 지불이므로 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%**입니다. 확인할 후보이지, 워시 트레이딩으로 판정한 값이 아닙니다.
구성한 반례 하나면 차이를 볼 수 있습니다. 시장조성 계정이 No 100개를 $0.985에 사고 9초 뒤 $0.986에 같은 수량을 팔면 수수료 전 차액은 $0.10입니다. 정상 재고 조정과 인위적인 거래량 순환 모두 짧은 간격·비슷한 수량·반대 방향 조건을 만족할 수 있습니다.
다음으로 필요한 것은 거래 상대방 관계, 지갑 간 자금 연결, 수수료와 재고 변화입니다. 이 동일 지갑 반전 필터는 WIRED가 인용한 “짝지어진 계정이 반복적으로 서로 거래”하는 분석과 식별 조건이 다릅니다.
5분 가격은 처음의 해석을 바꿨다
2026년 9월 22일 Adanech Yes 계약의 인접한 12시간 관측점 몇 개는 모두 0.0030이었습니다. 처음에는 “거래는 이어지지만 거친 표본 가격은 변하지 않는다”만 보였습니다. 5분 데이터로 바꾸니 이 해석은 불완전했습니다.
전체 시계열은 정상 5분 구간 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 규칙에 따라 정상 5분 관측과 분리하고, 정산 상태는 resolutions로 확인합니다. 이를 시세에 섞으면 실제로 없던 제로 가격 폭락을 그리게 됩니다.
가격 곡선이나 신호 분석만 한다면 이력 가격부터 사용해도 되며 모든 OrderFilled를 온체인에서 복원할 필요는 없습니다. 다만 지급 종점 제거는 정제의 한 단계입니다. 시간 범위, 누락 구간, 계약 정산을 확인하고, 실현 가능한 전략 수익을 추정하려면 체결 가정, 수수료와 슬리피지도 검증해야 합니다. 과거 구간 가격은 실제 체결 가능한 호가가 아닙니다.
재사용할 수 있는 확인 순서는 계약·시간·분모 통일, Yes/No와 BUY/SELL 구분, 로그 식별, 매칭 유형별 금액 정의입니다. 외부 연구도 공개 표 재계산과 분류기 재구축은 다른 작업입니다. 각 단계의 근거를 남기는 편이 워시 트레이딩 비율부터 논쟁하는 것보다 유용합니다. 주식 백테스트의 비슷한 분모 문제는 같은 종목이 반복 선정되면 퀀트 전략 승률을 믿을 수 있을까?에서 완전한 Python·SQL로 세 가지 집계 기준을 비교했습니다.
이 글은 데이터 정의와 엔지니어링을 다루며 거래 조언이 아닙니다. Gamma 금액과 국소 구간은 2026-10-20 UTC 스냅샷이고, MSR 공개 표·API 계약·고정 소스는 2026-10-21 UTC에 확인했습니다.