Eine WIRED-Recherche vom 15. Oktober 2026 rückte zwei Wahlmärkte in den Fokus. Unter Berufung auf MSR Decode schrieb WIRED, dass etwa 284 Millionen US-Dollar an Handel im Äthiopien-Markt auf wiederholte Käufe und Verkäufe zwischen gepaarten Konten zurückgingen. Polymarket erklärte, die Vorgänge untersucht und die betroffenen Konten für den Handel gesperrt zu haben.

Der Vorwurf verdient Prüfung. Seine Grundlage sind Zyklen zwischen Handelspartnern, nicht bloß ein hohes Volumen unter dem Namen eines unterlegenen Kandidaten. Wer dafür selbst die API öffnet, kann allerdings schon bei der ersten Auswertung falsch zählen. Steht der gesamte Betrag unter einem Kandidatennamen für Wetten auf dessen Sieg? Bedeuten drei Zeilen mit demselben Hash drei doppelte Trades?

Ich habe die kumulierten Beträge beider Events, zwei kleine Handelszeitfenster und drei Matching-Transaktionen auf Polygon geprüft. Ein hohes Volumen zeigt, wo man genauer hinschauen sollte; entscheidend ist die Transaktionsstruktur. In den zwei kleinen Fenstern entfielen mehr als 98 Prozent des nominalen Umsatzes auf Käufe und Verkäufe von No-Token zusammen. Das heißt nicht, dass 98 Prozent des Geldes für den Kauf von No eingesetzt wurden. Auch No-Kontrakte nahe einem Dollar können wiederholt den Besitzer wechseln. Sowohl normaler Handel als auch künstliche Kreisläufe können dieselbe Verteilung erzeugen; unterscheiden lassen sie sich erst anhand von Gegenparteien und Geldflüssen.

Für die Kontroverse und die Zahlendefinitionen genügt zunächst der Betragsvergleich. Wer einen Collector oder Indexer baut, kann zusätzlich das Python-Beispiel und die Regeln für Matching-Logs durcharbeiten.

Event, Condition, Token und kumulierter Umsatz

Ein Wahlevent bei Polymarket kann mehrere Kandidatenmärkte enthalten. Jeder hat eine eigene Condition sowie die beiden Ergebnis-Token Yes und No. Beide Token können jeweils gekauft oder verkauft werden.

event: das gesamte Wahlevent
  └─ condition: gewinnt ein bestimmter Kandidat?
       ├─ Yes token → BUY / SELL
       └─ No token  → BUY / SELL

Meine gespeicherten Gamma-API-Snapshots enthalten folgende Werte. Bei beiden Events entspricht die Summe von volume über die Teilmärkte dem Event-Gesamtwert. Dollarbeträge in den Tabellen behalten die Zahlenschreibweise der Quellen bei.

Offizielles Gamma-EventTeilmärkteGemeldeter kumulierter Umsatz
Next Prime Minister of Ethiopia33$286,138,162.70
(Old) Romania Election6$371,837,992.48
Summe39$657,976,155.18

Die rund 658 Millionen Dollar sind hier der kumulierte Umsatz über die gesamte Historie im API-Snapshot. Die WIRED-Überschrift bezieht sich dagegen auf Handel nach feststehendem Wahlausgang. Ich habe diese Gesamtbeträge nicht an den jeweiligen Zeitpunkten getrennt, zu denen die Ergebnisse klar wurden. Das ist ein Größenvergleich, keine Reproduktion des Handelsvolumens nach dem Ergebnis.

Welcher Nenner gehört zu MSRs 284 Millionen?

Teilt man die gerundeten $284,000,000 aus dem Bericht durch Gammas $286,138,162.70, erhält man etwa 99.25%. Die Division stimmt; Zähler und Nenner beziehen sich aber auf unterschiedliche Grundgesamtheiten.

MSRs öffentliche Studie und die zugehörige JSON-Tabelle liefern einen eigenen Nenner. Die Untersuchung umfasst 2026-04-28 bis 09-09 und die Polygon-Blöcke 86107336–93524955. Sieben äthiopische Teilmarktzeilen mit event_id=411239 enthalten jeweils turnover_usd und das von der Studie markierte recycled_usd. Ihre Summen:

Quelle und UmfangBetragGemessene Größe
MSR-Tabelle: 7 Märkte im untersuchten Blockbereich$283,964,639.89Summe von recycled_usd
Dieselbe Tabelle, dieselben 7 Märkte und Blöcke$285,027,224.40Zugehöriger Nenner turnover_usd
Gamma-Snapshot: alle 33 Märkte des Events$286,138,162.70Gemeldeter kumulierter Umsatz

Innerhalb derselben Tabelle ergibt sich $283,964,639.89 / $285,027,224.40 ≈ 99.63%. Das entspricht MSRs auf eine Nachkommastelle gerundeten 99.6%. Dieser hohe Anteil ist das Ergebnis für genau diese sieben Märkte im untersuchten Zeitraum.

Für einen Abgleich mit Gamma müssten die sieben condition_id-Werte, Anfangs- und Endblöcke sowie die Zählregeln für wirtschaftliche Fills übereinstimmen. MSR bewertet direkte Anteilstrades zum Geldpreis und die Erzeugung beziehungsweise Zusammenführung von Paaren mit einem Dollar pro Paar; jeder wirtschaftliche Fill zählt einmal. Unterschiedliche Pfade brauchen passende Betragsdefinitionen. size × price einer einzelnen API-Seite ersetzt sie nicht pauschal.

Reproduziert habe ich hier die Addition der öffentlichen Tabelle. MSR veröffentlicht die genauen Schwellenwerte seiner Episode-Klassifizierung nicht. Auch ist nicht geklärt, ob der öffentliche Census und die für WIRED beauftragte Analyse dieselbe eingefrorene Datenversion verwenden. Für eine unabhängige Rekonstruktion der Klassifizierung fehlen diese Informationen. Quelle und Nenner hinter „etwa 284 Millionen“ lassen sich dennoch nachprüfen.

No-Umsatz in zwei kleinen Zeitfenstern

Der Markt für Adanech Abiebie meldet kumuliert etwa 83,46 Millionen Dollar, der für Nicolae Ciucă rund 326,5 Millionen. Letzterer entspricht ungefähr 87,8 Prozent des Rumänien-Events. Der Kandidatenname ist auffällig, doch das tatsächlich gehandelte Asset muss bis zum Yes- oder No-Token aufgelöst werden.

Ich habe pro Markt die jüngsten 250 Taker-Datensätze gelesen und UTC-Fenster gewählt, deren beide Grenzen von den zurückgegebenen Zeitstempeln vollständig umschlossen wurden.

Markt und UTC-FensterDatensätzeSumme size × priceNo-Betrag: BUY + SELL
Adanech, 2026-09-01 bis 09-30, Ende exklusiv83$2,997.93$2,987.15; 99.64%
Ciucă, gesamter 2025-04-30128$95,930.59$94,609.25; 98.62%

outcome=No, side=BUY bedeutet No kaufen; outcome=No, side=SELL bedeutet No verkaufen. Beides geht in den Tabellenumsatz ein. Nettokäufe oder Positionsänderungen müssen getrennt nach Konto, Anteilsmenge und Richtung berechnet werden.

Zusammen sind das etwa 98.900 Dollar, nur 0.0241% des kumulierten Umsatzes der beiden Teilmärkte. Die Auswahl ist nicht zufällig. Ich habe diese Fenster gewählt, weil jeweils eine Seite vollständig ausreichte und sich Felder und Formeln so kontrollieren ließen. Sie illustrieren Definitionen, nicht die gesamte Historie beider Märkte.

Data API v2: Die aktuelle erste Seite ist kein historischer Snapshot

Der offizielle OpenAPI-Vertrag definiert condition als Filter für GET /v2/trades, die Liste im obersten Feld data und condition_id in jedem Trade. Der Seitencursor steht in pagination.next_cursor. taker_only=true liefert die Taker-Seite. Bei einer Condition-Abfrage ignoriert der Endpunkt start/end; nach Zeit muss deshalb nach dem Abruf gefiltert werden.

Eine Anfrage mit limit=250 ohne Cursor liefert die aktuell jüngste Seite, nicht meinen gespeicherten historischen Snapshot. Neue Einträge oder Korrekturen können ihren Zeitumfang verändern. Das folgende Skript summiert nur, wenn die Seite beide Grenzen überschreitet. Die 83 Datensätze samt Beträgen sind Ergebnisse des aufbewahrten Snapshots, keine zugesicherten Ausgaben bei jedem späteren Aufruf.

Python-Beispiel zur Prüfung des Zeitfensters

Als window_check.py speichern und mit python window_check.py starten. Das Programm lädt die aktuelle Seite und prüft Condition, Reihenfolge, Beträge und Zeitgrenzen, bevor es Summen ausgibt.

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')))

Der Request nennt einen ausdrücklichen User-Agent. Ein Header garantiert jedoch keine Freigabe aus jedem Netzwerk. Bei 403 sind die Zugangsbedingungen zu prüfen; bei 429/503 sollte die Antwort Retry-After berücksichtigt werden. Ein Netzwerkfehler ist kein leerer Datensatz.

Window not bracketed bedeutet, dass die Seite nicht weit genug zurückreicht. Dann mit denselben Filtern pagination.next_cursor folgen. Dieses Beispiel blättert nicht automatisch. Upper bound unproven bedeutet dagegen, dass sogar der jüngste Eintrag vor dem Fensterende liegt; ältere Seiten lösen das nicht. Marktende und Datenabdeckung müssen separat geklärt werden, statt die Prüfung zu löschen und eine Summe zu erzwingen.

Diese Prüfung ist bewusst konservativ. Ein Markt kann schon aufgehört haben zu handeln, sodass selbst die vollständige Historie keine Einträge nach dem Endpunkt enthält. Verifizierte Vollständigkeit oder eine belegte Endgrenze erlauben einen anderen Abdeckungsnachweis; das ist aber nicht die hier verwendete Bedingung, dass eine Seite beide Grenzen überquert.

CTF Exchange V2: Erst Logs identifizieren, dann Beträge definieren

Eine zusätzliche Adanech-Seite mit taker_only=false enthält 100 Zeilen, aber nur 49 verschiedene Transaktionshashes. 48 Hashes erscheinen mehrfach, maximal dreimal. Aus 100 - 49 = 51 vermeintliche Wash Trades abzuleiten verwechselt Antwortzeilen mit Blockchain-Transaktionen. Nur die erste Zeile je Hash zu behalten kann wiederum unterschiedliche Datensätze innerhalb einer Transaktion entfernen.

Der fest gepinnte CTF-Exchange-V2-Quellcode erklärt die Ereignishierarchie: Ein matchOrders verarbeitet eine Taker-Order und mehrere Maker-Orders. Jeder Maker-Fill emittiert ein OrderFilled. Nach der Taker-Verarbeitung folgen ein zusätzliches zusammenfassendes OrderFilled und unmittelbar danach OrdersMatched. Die letzten beiden entstehen in _emitTakerFilledEvents.

Die drei geprüften Transaktionen enthalten folgende Ereigniszahlen. Die Tabelle belegt deren Anzahl, nicht identische Geldbeträge auf beiden Seiten.

Polygon-TransaktionOrderFilledOrdersMatchedEreignisebenen
A211 Maker-Detail + 1 Taker-Summe
B312 Maker-Details + 1 Taker-Summe
C211 Maker-Detail + 1 Taker-Summe

Es sind Order-Datensätze; ein Wallet kann mehrere Orders haben. Auch OrderFilled.maker der Taker-Zusammenfassung enthält takerOrder.maker, also den Ersteller der aggressiven Order. Allein der Feldname maker identifiziert deshalb keine passive Ausführung.

Direkter Handel desselben Tokens und Yes/No-Paarbildung sind verschiedene Rechnungen

Ein konstruiertes BUY-gegen-SELL-Beispiel desselben Ergebnis-Tokens zeigt zunächst die doppelte Summierung: Jemand kauft 10 Anteile zu je $0.60 und bedient Verkaufsorders über 4 und 6 Anteile. Die Maker-Details betragen $2.40 und $3.60, die Taker-Summe $6. Werden alle drei als unabhängige wirtschaftliche Beträge addiert, entstehen fälschlich $12. Das ist keine Rekonstruktion der Beträge von A/B/C.

MINT/MERGE über Yes und No benötigt eine andere Rechnung. In einem konstruierten MINT ohne Gebühren und Restbeträge bezahlt eine Partei $4 für 10 Yes-Anteile, die andere $6 für 10 No-Anteile. Zusammen entstehen $10 Sicherheiten. Es sind Zahlungen für unterschiedliche Ergebnis-Token; die $6 sind nicht schon deshalb ein Duplikat der $4, weil sie in einer Taker-Zusammenfassung stehen.

Direkter Trade: Maker-Details 2.40 + 3.60 = Taker-Summe 6.00
MINT:          Yes-Zahlung 4.00 + No-Zahlung 6.00 = Sicherheiten 10.00

Im vereinfachten reinen MINT mit gleichen Einheiten, ohne Gebühren und Restbeträge entspricht die Taker-Zahlung den gesamten Paarsicherheiten abzüglich der Maker-Zahlung. Die empfangenen Ergebnisanteile werden auf der anderen Seite zusammengefasst. Produktiv müssen außerdem side, tokenId, die Dezimalstellen der Sicherheiten, Gebühren und Restbeträge bekannt sein. Der Enum-Name COMPLEMENTARY bezeichnet im gepinnten Code direktes BUY/SELL-Matching desselben Tokens. Zwei Käufe oder zwei Verkäufe komplementärer Ergebnis-Token laufen dagegen über MINT beziehungsweise MERGE—der Name kann in die Irre führen.

Maker-Details und Taker-Zusammenfassungen bleiben in getrennten Tabellen. Die Log-Klassifizierung bestimmt die Rolle eines Datensatzes. Die Definition des wirtschaftlichen Umsatzes folgt erst nach Identifikation des Matching-Typs. Deshalb kann die Ereigniszählung stimmen und der Geldalgorithmus trotzdem falsch sein.

Ein Summenereignis identifizieren

Doppelte Abfragen roher Ereignisse werden anhand von (chain_id, transaction_hash, log_index) dedupliziert. Gemeint ist hier der blockweite logIndex der RPC-Antwort. Dafür einen Lookup aufbauen, den Wert nicht als direkten Array-Index für receipt.logs benutzen. Verwendet eine Indexer-Datenbank transaktionslokale Indizes, muss diese Zählweise durchgängig gelten und Reihenfolge sowie unmittelbare Nachbarschaft erhalten.

Der folgende Pseudocode gilt für ein vollständiges erfolgreiches Receipt eines Emitters, der nachweislich den gepinnten V2-Pfad verwendet. ABI-Decodierung sowie einheitliche Adress- und Integerdarstellung sind Voraussetzung. Scheitert ein require, endet die automatische Aggregation dieses Receipts.

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 und order_fills enthalten nur den bestätigten Vertrag. Vor der letzten Zeile müssen alle relevanten OrderFilled zu vollständigen, erklärbaren Match-Gruppen gehören. Fehlende Logs, widersprüchliche Felder oder nicht geschlossene Gruppen werden zur Untersuchung zurückgestellt. Eine Transaktion kann mehrere Match-Aufrufe enthalten; jede Gruppe wird separat zugeordnet. Bricht eine spätere Version die Nachbarschaft, ist die Aggregation dieses Receipts anzuhalten und die Version neu zu prüfen. Nicht einfach den letzten Eintrag mit passendem orderHash in der gesamten Transaktion nehmen: Dieselbe Order kann über mehrere Aufrufe teilweise ausgeführt werden. Beide Tabellen behalten ihre Beträge, die anschließend je nach direktem Handel, MINT oder MERGE interpretiert werden.

Schnelle Richtungswechsel: Der Schwellenwert verändert das Ergebnis

Für beide Fenster verwendete ich dieselbe Regel: nach Proxy-Wallet-Kennung und Ergebnis-Token gruppieren, dann gegenläufige Trades mit höchstens 2 Prozent Mengenabweichung paaren. Jeder Datensatz darf nur einmal verwendet werden. Allein der erlaubte Zeitabstand verändert die Treffer stark.

Maximaler PaarabstandAdanech: 83 FensterdatensätzeCiucă: 128 Fensterdatensätze
30 Sekunden1 Paar2 Paare
60 Sekunden2 Paare37 Paare
5 Minuten2 Paare39 Paare
15 Minuten19 Paare39 Paare
60 Minuten20 Paare44 Paare

Die von der 15-Minuten-Regel ausgewählten Datensätze entsprechen 8.39% und 2.50% des jeweiligen Fensterumsatzes. Das sind Prüfungskandidaten, keine festgestellten Wash Trades.

Ein konstruiertes Gegenbeispiel reicht: Ein Market-Making-Konto kauft 100 No-Anteile zu $0.985 und verkauft neun Sekunden später dieselbe Menge zu $0.986. Die Differenz vor Gebühren beträgt $0.10. Normale Bestandsanpassung und absichtliche Volumenkreisläufe können beide die Regel „kurze Zeit, ähnliche Menge, entgegengesetzte Richtung“ erfüllen.

Als Nächstes wären Gegenparteibeziehungen, walletübergreifende Geldflüsse, Gebühren und Bestandsänderungen zu untersuchen. Dieser Richtungswechsel-Filter innerhalb eines Wallets hat andere Erkennungskriterien als die von WIRED zitierten gepaarten Konten, die wiederholt miteinander handeln.

Fünf-Minuten-Preise korrigierten den ersten Eindruck

Beim Adanech-Yes-Kontrakt lagen am 22. September 2026 mehrere benachbarte Zwölf-Stunden-Beobachtungen bei 0.0030. Zunächst war also nur zu sehen: Der Handel geht weiter, an den groben Messpunkten bleibt der Preis gleich. Fünf-Minuten-Daten zeigten, dass diese Interpretation unvollständig war.

Die vollständige Reihe umfasst 288 normale Fünf-Minuten-Intervalle. Der Vergleich aufeinanderfolgender Punkte findet drei Preisänderungen. Die Tabelle zeigt nur den Ausgangspunkt vor der ersten Änderung und den Beginn der drei Änderungen. Unveränderte Intervalle sind ausgelassen; die vier Zeilen sind keine fortlaufenden Stichproben.

UTC-ZeitHistorischer Yes-Intervallpreis
20:250.0030
20:300.0020
20:350.0025
21:000.0030

Die feinere Reihe zeigt den Rückgang und die Erholung, die im groben Raster verschwinden. Es bleiben Intervallpreise; kausaler Preiseinfluss erfordert zusätzlich damalige Quotes, Tiefe und Nachrichtenzeitpunkte.

Die Historienabfrage für den abgerechneten Kontrakt fügte außerdem am Ende einen Auszahlungspunkt mit resolution_seconds=0, price=0 an. Laut API-Konvention gehört er getrennt von normalen Fünf-Minuten-Beobachtungen; den Abrechnungsstatus prüft man über resolutions. In einer Kursreihe würde er einen fiktiven Absturz auf null zeichnen.

Für Kurskurven oder Signalanalyse kann man mit historischen Preisen beginnen, ohne jedes OrderFilled onchain zu rekonstruieren. Der entfernte Auszahlungspunkt ist aber nur ein Reinigungsschritt. Zeitabdeckung, Lücken und Abrechnung müssen ebenfalls stimmen; für erzielbare Strategierenditen kommen Ausführungsannahmen, Gebühren und Slippage hinzu. Historische Intervallpreise sind keine ausführbaren Quotes.

Wiederverwendbar ist die Reihenfolge: Verträge, Zeit und Nenner abstimmen; Yes/No von BUY/SELL trennen; Logs identifizieren; anschließend Beträge nach Match-Typ definieren. Auch bei externen Studien sind Tabellenrechnung und Rekonstruktion des Klassifikators zwei verschiedene Aufgaben. Die Belege pro Ebene festzuhalten hilft mehr, als zuerst über einen Prozentsatz zu streiten. Ein ähnliches Nennerproblem bei Aktien-Backtests zeigt Wie aussagekräftig ist die Trefferquote, wenn dieselbe Aktie immer wieder ausgewählt wird? mit vollständigem Python und SQL für drei statistische Definitionen.

Dieser Beitrag behandelt Datenabgrenzung und Implementierung, nicht Handelsempfehlungen. Gamma-Beträge und lokale Fenster stammen aus Snapshots vom 2026-10-20 UTC; MSR-Tabelle, API-Vertrag und gepinnter Quellcode wurden am 2026-10-21 UTC geprüft.