# Polymarket e wash trading: le insidie dei volumi nella Data API e nei log di matching

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

[Un'inchiesta di WIRED del 15 ottobre 2026](https://www.wired.com/story/over-dollar600-million-traded-on-polymarket-election-contracts-after-races-ended/) ha richiamato l'attenzione su due mercati elettorali. Citando MSR Decode, attribuiva circa 284 milioni di dollari di scambi nel mercato etiope ad acquisti e vendite ripetuti fra coppie di account. Polymarket ha risposto di aver indagato e bloccato gli account coinvolti.

L'accusa merita una verifica. Si basa su cicli fra controparti, non soltanto sul fatto che un candidato sconfitto mostri un volume elevato. Ma aprendo l'API per controllare si può sbagliare già la prima somma. Tutto l'importo sotto il nome di un candidato rappresenta puntate sulla sua vittoria? Un hash che compare tre volte indica tre operazioni duplicate?

Ho confrontato i totali cumulati dei due eventi, due piccole finestre di scambi e tre transazioni di matching su Polygon. Un volume alto indica dove cercare; ciò che va scomposto è la struttura degli scambi. Nelle due finestre, oltre il 98% dell'importo nozionale derivava da **acquisti e vendite di token No sommati insieme**, non da «il 98% del denaro compra la sua sconfitta». Anche i contratti No vicini a un dollaro possono passare ripetutamente di mano. Sia gli scambi normali sia i cicli artificiali possono generare questa distribuzione: per distinguerli servono controparti e flussi di fondi.

Per la controversia e le definizioni numeriche, si può partire dal confronto degli importi. Chi scrive un collector o un indexer può approfondire il codice Python e le regole dei log.

## Event, condition, token e importi cumulati

In Polymarket un evento elettorale può contenere più mercati di candidati. Ogni mercato ha una propria condition e due token di risultato, Yes e No. Entrambi possono essere comprati oppure venduti.

```text
event: l'intero evento elettorale
  └─ condition: vincerà un determinato candidato?
       ├─ Yes token → BUY / SELL
       └─ No token  → BUY / SELL
```

Gli snapshot della Gamma API che ho conservato riportano i seguenti valori. Per ciascun evento, la somma di `volume` dei mercati componenti coincide con il totale. Le tabelle mantengono la notazione numerica delle fonti per gli importi in dollari.

| Evento ufficiale Gamma | Mercati componenti | Controvalore cumulato dichiarato |
| --- | ---: | ---: |
| [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 |
| **Totale** | **39** | **$657,976,155.18** |

Questi **circa 658 milioni di dollari sono il cumulato dell'intera storia negli snapshot API**. Il titolo di WIRED riguarda gli scambi successivi a un esito elettorale ormai deciso. Non ho suddiviso i cumulati rispetto al momento in cui ciascun risultato era diventato chiaro. È un confronto di scala, non la ricostruzione del volume dopo l'esito.

## Qual è il denominatore dei 284 milioni di MSR?

Dividendo i $284,000,000 arrotondati dell'articolo per i $286,138,162.70 di Gamma si ottiene circa **99.25%**. La divisione è corretta, ma numeratore e denominatore hanno perimetri diversi.

Lo [studio pubblico di MSR](https://msrdecode.com/cases/artificial-volume-census.html) e la relativa [tabella JSON](https://msrdecode.com/artificial-volume-census.json) forniscono un denominatore proprio. La ricerca copre **dal 2026-04-28 al 09-09**, con i blocchi Polygon `86107336–93524955`. Sette righe dei mercati etiopi con `event_id=411239` riportano `turnover_usd` e il `recycled_usd` segnalato dalla ricerca. Sommando le sette righe:

| Fonte e perimetro | Importo | Oggetto misurato |
| --- | ---: | --- |
| Tabella MSR: 7 mercati nell'intervallo di blocchi studiato | $283,964,639.89 | Totale `recycled_usd` |
| Stessa tabella, stessi 7 mercati e blocchi | $285,027,224.40 | Denominatore `turnover_usd` corrispondente |
| Snapshot Gamma: tutti i 33 mercati dell'evento | $286,138,162.70 | Controvalore cumulato dichiarato |

**Il rapporto nella stessa tabella è $283,964,639.89 / $285,027,224.40 ≈ 99.63%**, coerente con il 99.6% riportato da MSR a una cifra decimale. Quella percentuale elevata riguarda esattamente quei sette mercati e quel periodo di studio.

Per riconciliare il dato con Gamma andrebbero allineati i sette `condition_id`, i blocchi iniziale e finale e le regole di conteggio degli eseguiti economici. MSR valuta gli scambi diretti di quote al prezzo monetario, la creazione o fusione di coppie a un dollaro per coppia, contando ogni fill economico una sola volta. Percorsi diversi richiedono definizioni diverse degli importi: il `size × price` di un solo lato dell'API non è un sostituto universale.

Quello che ho riprodotto è l'addizione della tabella pubblica. MSR non pubblica le soglie precise per classificare gli episodi di circolazione; non è stato neppure confermato che il census pubblico e l'analisi commissionata da WIRED usino la medesima versione congelata dei dati. Queste informazioni mancano per ricostruire la classificazione in modo indipendente. Si possono però verificare fonte pubblica e denominatore dei «circa 284 milioni».

## Scambi di No in due piccole finestre

Il mercato Adanech Abiebie registra circa 83,46 milioni di dollari cumulati; quello di Nicolae Ciucă circa 326,5 milioni, pari a circa l'87,8% dell'evento rumeno. Il nome del candidato risalta, ma l'asset scambiato va identificato fino al token Yes o No.

Ho letto i 250 record taker più recenti di ciascun mercato e scelto finestre UTC i cui due estremi risultavano interamente delimitati dai timestamp restituiti.

| Mercato e finestra UTC | Record | Somma di `size × price` | Importo No: BUY + SELL |
| --- | ---: | ---: | ---: |
| Adanech, dal 2026-09-01 al 09-30, estremo finale escluso | 83 | $2,997.93 | $2,987.15; 99.64% |
| Ciucă, tutto il 2025-04-30 | 128 | $95,930.59 | $94,609.25; 98.62% |

`outcome=No, side=BUY` significa comprare No; `outcome=No, side=SELL` significa venderlo. Entrambi contribuiscono al controvalore della tabella. Per acquisti netti o variazioni di posizione occorre un calcolo distinto per account, numero di quote e direzione.

Le due finestre totalizzano circa 98.900 dollari, appena lo **0.0241%** del cumulato dei due mercati, e non costituiscono un campione casuale. Le ho scelte perché una singola pagina copriva l'intera finestra, rendendo controllabili campi e formule. Servono a illustrare le definizioni, non a descrivere la storia completa dei mercati.

## Data API v2: la pagina attuale non è uno snapshot storico

Il [contratto OpenAPI ufficiale](https://data-api.polymarket.com/v2/openapi.json) specifica `condition` come filtro di `GET /v2/trades`, la lista `data` al livello principale e `condition_id` in ogni `Trade`. La paginazione usa `pagination.next_cursor`. `taker_only=true` restituisce il lato taker. Nelle query per condition, `start/end` vengono ignorati: il filtro temporale va quindi applicato dopo il recupero.

**Una richiesta `limit=250` senza cursore ottiene la pagina più recente in quel momento, non blocca lo snapshot storico che ho salvato.** Nuovi record e correzioni possono modificarne la copertura. Lo script seguente aggrega solo se la pagina oltrepassa entrambi i confini temporali. Gli 83 record e i relativi importi appartengono allo snapshot conservato; non sono un risultato promesso per qualsiasi esecuzione futura.

<details>
<summary>Apri l'esempio Python di controllo della finestra</summary>

Salva come `window_check.py` ed esegui `python window_check.py`. Il programma richiede la pagina corrente e controlla condition, ordinamento, importi e limiti temporali prima di decidere se stampare il riepilogo.

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

La richiesta identifica lo script con uno User-Agent esplicito, ma un header non garantisce accesso da ogni rete. Un 403 richiede di controllare le condizioni di accesso; per 429/503 va considerato il `Retry-After` del servizio. Un errore di rete non è un dataset vuoto.

`Window not bracketed` indica che la pagina non arriva abbastanza indietro: bisogna seguire `pagination.next_cursor` mantenendo gli stessi filtri. L'esempio non pagina automaticamente. `Upper bound unproven` significa invece che persino il record più recente precede la fine della finestra: pagine ancora più vecchie non risolvono il problema. Vanno verificati separatamente la cessazione degli scambi e la copertura, senza eliminare il controllo per forzare un totale.

È un test volutamente prudente. Se il mercato ha già smesso di negoziare, anche la storia completa potrebbe non avere record successivi al limite finale. Una storia completa o un confine di terminazione verificato consentono un'altra prova di copertura, diversa da «una pagina supera entrambi i limiti» usata qui.

</details>

## CTF Exchange V2: identificare i log prima di scegliere gli importi

Una pagina separata di Adanech con `taker_only=false` conteneva 100 righe, ma solo 49 hash di transazione diversi. Quarantotto comparivano più volte, con un massimo di 3 righe per hash. Fare `100 - 49 = 51` e chiamare il resto wash trading confonde righe della risposta e transazioni onchain. Tenere solo la prima riga per hash può a sua volta eliminare record distinti della stessa transazione.

Il [sorgente fissato di CTF Exchange V2](https://github.com/Polymarket/ctf-exchange-v2/blob/ccc0596074f4dfd62c944fbca4de252893b82b4b/src/exchange/mixins/Trading.sol) chiarisce la gerarchia: una chiamata `matchOrders` elabora un ordine taker e un insieme di ordini maker. Ogni eseguito maker emette un `OrderFilled`; completato il taker, viene emesso un altro `OrderFilled` riepilogativo, immediatamente seguito da `OrdersMatched`. Gli ultimi due nascono in [`_emitTakerFilledEvents`](https://github.com/Polymarket/ctf-exchange-v2/blob/ccc0596074f4dfd62c944fbca4de252893b82b4b/src/exchange/mixins/Events.sol).

Le tre transazioni controllate mostrano questi conteggi. **La tabella verifica gli eventi, non l'uguaglianza degli importi dei due lati.**

| Transazione Polygon | `OrderFilled` | `OrdersMatched` | Livelli degli eventi |
| --- | ---: | ---: | --- |
| [A](https://polygon.blockscout.com/tx/0x3bbc4f9c0363cf8fc1a89031183a6ae2bd9eb972a1d9b59acf87021118e0f5f6) | 2 | 1 | 1 dettaglio maker + 1 riepilogo taker |
| [B](https://polygon.blockscout.com/tx/0x243ebb2cddf06e31f85fe3cdcb94a7d839fd029cb4d58e68ede64cbe7a994dc3) | 3 | 1 | 2 dettagli maker + 1 riepilogo taker |
| [C](https://polygon.blockscout.com/tx/0xee9d91888a8109cb284d7c2a9980694738db07534786f985c501ee0b1376bfd8) | 2 | 1 | 1 dettaglio maker + 1 riepilogo taker |

Si contano record di ordini; un wallet può avere più ordini. Anche `OrderFilled.maker` nel riepilogo taker contiene `takerOrder.maker`, il creatore dell'ordine aggressore. Il nome del campo `maker` da solo non identifica un'esecuzione passiva.

### Scambio diretto dello stesso token e coppia Yes/No sono contabilità diverse

Un **esempio costruito di BUY contro SELL sullo stesso token di risultato** mostra il doppio conteggio: si comprano 10 quote a $0.60, eseguendo ordini di vendita da 4 e 6 quote. I dettagli maker valgono $2.40 e $3.60, il totale taker $6. Sommando tutte e tre le righe come importi economici indipendenti si ottiene il falso $12. Non è la ricostruzione dei valori di A/B/C.

MINT/MERGE fra Yes e No richiedono un altro calcolo. In un **MINT costruito senza commissioni né residui**, una parte paga $4 per 10 Yes e l'altra $6 per 10 No, formando $10 di garanzia complessiva. Sono pagamenti per token diversi: i $6 non duplicano i $4 solo perché compaiono in un riepilogo taker.

```text
Scambio diretto: dettagli maker 2.40 + 3.60 = totale taker 6.00
MINT:           pagamento Yes 4.00 + pagamento No 6.00 = garanzia 10.00
```

Nel MINT puro semplificato, a unità allineate e senza commissioni o residui, il pagamento in garanzia del taker è la garanzia complessiva della coppia meno il pagamento maker; le quote di risultato ricevute vengono aggregate sull'altro lato. L'implementazione reale deve identificare anche `side`, `tokenId`, precisione della garanzia, commissioni e residui. Attenzione al nome: nel sorgente fissato, `COMPLEMENTARY` indica il matching diretto BUY/SELL dello stesso token. Due acquisti o due vendite fra token di risultato complementari usano invece `MINT` o `MERGE`.

**Dettagli maker e riepiloghi taker vanno conservati in tabelle separate.** Classificare il log identifica il ruolo del record; il controvalore economico si definisce dopo aver riconosciuto il tipo di matching. Contare correttamente gli eventi non assicura un algoritmo monetario corretto.

### Identificare l'evento riepilogativo

Per eliminare acquisizioni ripetute degli eventi grezzi si usa `(chain_id, transaction_hash, log_index)`. Qui si intende il **`logIndex` a livello di blocco restituito dal RPC**. Si costruisce una mappa per quel valore, senza usarlo direttamente come indice dell'array `receipt.logs`. Se l'indexer usa un indice interno alla transazione, bisogna adottarlo coerentemente e conservare ordine e adiacenza originali.

Il seguente è ancora pseudocodice, limitato a una receipt completa e riuscita e a un emittente confermato per il percorso V2 fissato. Prima servono decodifica ABI e normalizzazione di indirizzi e interi. Un `require` fallito interrompe l'aggregazione automatica di quella receipt.

```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` e `order_fills` includono solo il contratto confermato. Prima dell'ultima riga, ogni `OrderFilled` rilevante deve appartenere a un gruppo di matching completo e spiegabile. Log assenti, campi incoerenti o gruppi non chiusi vanno messi da parte per l'indagine. Una transazione può contenere più chiamate: ogni gruppo si abbina separatamente. Se una versione futura rompe l'adiacenza, si sospende l'aggregazione e si controlla la versione; non si ripiega sull'ultima riga con lo stesso `orderHash` nell'intera transazione, perché un ordine può essere eseguito parzialmente in più chiamate. Le due tabelle conservano gli importi, poi interpretati secondo scambio diretto, MINT o MERGE.

## Le inversioni rapide dipendono dalla soglia

Ho applicato la stessa regola alle due finestre: raggruppare per identificatore del proxy wallet e token di risultato, poi cercare operazioni di verso opposto con quantità diverse al massimo del 2%. Ogni record può essere abbinato una volta sola. Cambiare soltanto l'intervallo massimo modifica molto i risultati.

| Intervallo massimo | Adanech: 83 record nella finestra | Ciucă: 128 record nella finestra |
| --- | ---: | ---: |
| 30 secondi | 1 coppia | 2 coppie |
| 60 secondi | 2 coppie | 37 coppie |
| 5 minuti | 2 coppie | 39 coppie |
| 15 minuti | 19 coppie | 39 coppie |
| 60 minuti | 20 coppie | 44 coppie |

I record selezionati dalla regola dei 15 minuti rappresentano **8.39% e 2.50%** dei totali delle rispettive finestre. Sono candidati da esaminare, non wash trade già accertati.

Basta un controesempio costruito: un account di market making compra 100 No a $0.985 e nove secondi dopo vende la stessa quantità a $0.986, con uno scarto di $0.10 prima delle commissioni. Un normale aggiustamento dell'inventario e un ciclo intenzionale possono entrambi rispettare i criteri di poco tempo, quantità simile e direzione opposta.

Servono poi rapporti fra controparti, flussi fra wallet, commissioni e variazioni d'inventario. Questo filtro di inversioni nello stesso wallet usa condizioni diverse dall'analisi di «account accoppiati che scambiano ripetutamente tra loro» citata da WIRED.

## I prezzi a cinque minuti hanno corretto il primo giudizio

Per il contratto Yes di Adanech, il 22 settembre 2026 diversi punti adiacenti campionati ogni dodici ore valevano tutti `0.0030`. All'inizio si vedevano scambi ancora in corso senza cambiamento ai punti grossolani. Passando a cinque minuti, quella lettura si è rivelata incompleta.

La serie completa contiene 288 intervalli ordinari da cinque minuti. Confrontare i punti consecutivi individua tre variazioni. La tabella mostra solo **il riferimento prima del cambiamento e l'inizio dei tre movimenti**, omettendo gli intervalli invariati: le quattro righe non sono campioni consecutivi.

| Ora UTC | Prezzo storico dell'intervallo Yes |
| --- | ---: |
| 20:25 | 0.0030 |
| 20:30 | 0.0020 |
| 20:35 | 0.0025 |
| 21:00 | 0.0030 |

La maggiore risoluzione mostra calo e recupero nascosti dal campionamento grossolano. Sono comunque prezzi d'intervallo; per un impatto causale servono quotazioni, profondità e tempi delle informazioni contemporanei.

La query per il contratto già risolto ha inoltre aggiunto un punto finale di pagamento con `resolution_seconds=0, price=0`. Secondo la [convenzione API](https://data-api.polymarket.com/v2/docs), va separato dai punti ordinari a cinque minuti, verificando lo stato su [resolutions](https://data-api.polymarket.com/v2/resolutions?condition=0x7d0aaf81bbd3fd73b6a1651cce08a452c0cbf9c0cbb4520ce0f981065b639d88). Mescolarlo ai prezzi disegnerebbe un crollo a zero inesistente.

Per curve di prezzo o analisi di segnali si può iniziare dallo storico senza ricostruire onchain ogni `OrderFilled`. Eliminare il punto di pagamento è solo un passaggio di pulizia: vanno controllati copertura temporale, intervalli mancanti e risoluzione. Stimare rendimenti realizzabili richiede inoltre ipotesi di esecuzione, commissioni e slippage. Il prezzo storico di un intervallo non è una quotazione eseguibile.

La sequenza riutilizzabile è **allineare contratti, tempi e denominatori; separare Yes/No da BUY/SELL; identificare i log e scegliere gli importi in base al tipo di matching**. Anche per gli studi esterni, ricalcolare una tabella e ricostruire il classificatore sono due lavori distinti. Conservare le prove di ogni livello è più utile che discutere subito una percentuale. Un problema analogo nei backtest azionari è trattato in [Il tasso di successo è affidabile se viene selezionata sempre la stessa azione?](/it/posts/quant-winrate-repeated-stock-picks/), con Python e SQL completi per confrontare tre definizioni statistiche.

*L'articolo discute definizioni dei dati e implementazione, non fornisce consigli di trading. Gli importi Gamma e le finestre locali derivano da snapshot del 2026-10-20 UTC; tabella MSR, contratto API e sorgente fissato sono stati verificati il 2026-10-21 UTC.*
