# Polymarket y el wash trading: trampas de volumen en Data API y los logs de ejecución

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

[Una investigación de WIRED del 15 de octubre de 2026](https://www.wired.com/story/over-dollar600-million-traded-on-polymarket-election-contracts-after-races-ended/) puso el foco en dos mercados electorales. Citando a MSR Decode, atribuyó unos 284 millones de dólares de operaciones en el mercado de Etiopía a compras y ventas repetidas entre cuentas emparejadas. Polymarket respondió que había investigado y bloqueado las operaciones de las cuentas implicadas.

La acusación merece comprobarse. Se apoya en ciclos entre contrapartes, no simplemente en que un candidato perdedor acumule mucho volumen. Pero, al abrir la API para investigarlo, es fácil equivocarse ya en la primera suma. ¿Todo el importe bajo el nombre de un candidato son apuestas a su victoria? ¿Un hash que aparece tres veces equivale a tres operaciones duplicadas?

He contrastado los importes acumulados de ambos eventos, dos ventanas pequeñas de operaciones y tres transacciones de cruce en Polygon. Un volumen alto indica dónde mirar; lo que hay que descomponer es la estructura de las operaciones. En estas dos ventanas, más del 98% del nominal correspondía a **compras y ventas de tokens No sumadas**, no a que «el 98% del dinero estuviera comprando su derrota». Los contratos No cercanos a un dólar también pueden cambiar de manos repetidamente. Tanto las operaciones normales como los ciclos artificiales pueden producir esa distribución; para distinguirlos hacen falta contrapartes y flujos de fondos.

Para entender la controversia y las definiciones basta con empezar por los importes. Quien construya un recopilador o indexador puede desplegar además el ejemplo de Python y las reglas de los logs.

## Event, condition, token e importe acumulado

En Polymarket, un evento electoral puede contener varios mercados de candidatos. Cada mercado tiene su propia condition y dos tokens de resultado: Yes y No. Cualquiera de los dos se puede comprar o vender.

```text
event: el evento electoral completo
  └─ condition: ¿gana un candidato concreto?
       ├─ Yes token → BUY / SELL
       └─ No token  → BUY / SELL
```

Las instantáneas de Gamma API que conservé muestran lo siguiente. En cada evento, la suma de `volume` de sus mercados coincide con el total del evento. Las tablas mantienen la notación numérica de las fuentes para los importes en dólares.

| Evento oficial de Gamma | Mercados incluidos | Importe acumulado declarado |
| --- | ---: | ---: |
| [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 |
| **Total** | **39** | **$657,976,155.18** |

Los **aproximadamente 658 millones de dólares son el acumulado de todo el historial en estas instantáneas**. El titular de WIRED habla de operaciones posteriores a que las elecciones estuvieran decididas. Yo no he separado estos acumulados según el momento en que quedó claro cada resultado. Es una comparación de escala, no una reconstrucción del volumen posterior al resultado.

## ¿Cuál es el denominador de los 284 millones de MSR?

Dividir los $284,000,000 redondeados de la noticia entre los $286,138,162.70 de Gamma da aproximadamente **99.25%**. La división es correcta, pero numerador y denominador tienen ámbitos distintos.

El [estudio público de MSR](https://msrdecode.com/cases/artificial-volume-census.html) y su [tabla JSON](https://msrdecode.com/artificial-volume-census.json) incluyen su propio denominador. Cubren **del 2026-04-28 al 09-09**, con los bloques de Polygon `86107336–93524955`. Siete filas de mercados etíopes con `event_id=411239` contienen `turnover_usd` y el `recycled_usd` señalado por el estudio. Al sumarlas:

| Fuente y ámbito | Importe | Qué se mide |
| --- | ---: | --- |
| Tabla de MSR: 7 mercados dentro del rango de bloques estudiado | $283,964,639.89 | Suma de `recycled_usd` |
| Misma tabla, mismos 7 mercados y bloques | $285,027,224.40 | Denominador `turnover_usd` correspondiente |
| Instantánea de Gamma: los 33 mercados del evento | $286,138,162.70 | Importe acumulado declarado |

**La proporción dentro de la misma tabla es $283,964,639.89 / $285,027,224.40 ≈ 99.63%**, coherente con el 99.6% que MSR muestra con un decimal. Es precisamente el resultado de esos siete mercados durante ese intervalo de investigación.

Para conciliarlo con Gamma habría que igualar los siete `condition_id`, los bloques inicial y final y la forma de contar las ejecuciones económicas. MSR valora los intercambios directos de participaciones por su precio en efectivo y la creación o fusión de pares a un dólar por par, contando cada fill económico una sola vez. Cada ruta necesita su definición de importe; no puede sustituirse todo por el `size × price` de un único lado de la API.

Lo que he reproducido es la suma de la tabla pública. MSR no publica los umbrales exactos con los que clasifica los episodios de circulación, y tampoco está confirmado que su censo público y el análisis encargado por WIRED usen la misma versión congelada de datos. Para reconstruir la clasificación de forma independiente falta esa información. Sí se pueden comprobar la fuente pública y el denominador de «unos 284 millones».

## Operaciones con No en dos ventanas pequeñas

El mercado de Adanech Abiebie acumula unos 83,46 millones de dólares; el de Nicolae Ciucă, unos 326,5 millones, aproximadamente el 87,8% del evento rumano. El nombre del candidato destaca, pero el activo negociado debe identificarse como Yes o No.

Leí los 250 registros taker más recientes de cada mercado y elegí ventanas UTC cuyos dos extremos quedaban cubiertos por los timestamps devueltos.

| Mercado y ventana UTC | Registros | Suma de `size × price` | Importe No: BUY + SELL |
| --- | ---: | ---: | ---: |
| Adanech, del 2026-09-01 al 09-30, final excluido | 83 | $2,997.93 | $2,987.15; 99.64% |
| Ciucă, todo el 2025-04-30 | 128 | $95,930.59 | $94,609.25; 98.62% |

`outcome=No, side=BUY` significa comprar No; `outcome=No, side=SELL`, vender No. La tabla incluye ambos importes. Para medir compras netas o cambios de posición hay que calcular aparte por cuenta, cantidad de participaciones y dirección.

Las ventanas suman unos 98.900 dólares, apenas el **0.0241%** del acumulado de esos dos mercados. No son una muestra aleatoria. Las elegí porque una sola página cubría cada intervalo completo y permitía revisar campos y fórmulas. Sirven para explicar definiciones, no para retratar toda la historia de los mercados.

## Data API v2: la página actual no es una instantánea histórica

El [contrato OpenAPI oficial](https://data-api.polymarket.com/v2/openapi.json) establece `condition` como filtro de `GET /v2/trades`, una lista `data` en el nivel superior y `condition_id` en cada `Trade`. La paginación usa `pagination.next_cursor`. `taker_only=true` devuelve el lado taker. En las consultas por condition se ignoran `start/end`, por lo que el filtro temporal se aplica después de recuperar los datos.

**Una petición `limit=250` sin cursor obtiene la página más reciente en ese momento, no fija la instantánea que guardé.** Las nuevas operaciones y las correcciones históricas pueden modificar su cobertura. El script solo suma si la página cruza ambas fronteras temporales. Los 83 registros y sus importes pertenecen a la instantánea conservada; no son una promesa de salida en cualquier ejecución futura.

<details>
<summary>Desplegar el ejemplo de comprobación de ventana en Python</summary>

Guárdalo como `window_check.py` y ejecuta `python window_check.py`. Solicita la página actual, comprueba contrato, orden, importes y límites temporales, y después decide si puede imprimir el total.

```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 petición identifica el script mediante un User-Agent explícito, pero una cabecera no garantiza acceso desde cualquier red. Ante un 403, revisa las condiciones de acceso; con 429/503, atiende al `Retry-After` del servicio. Un fallo de red no equivale a un conjunto vacío.

`Window not bracketed` indica que la página no retrocede lo suficiente. Sigue `pagination.next_cursor` conservando todos los filtros. Este ejemplo no pagina automáticamente. `Upper bound unproven` significa que hasta el registro más nuevo es anterior al final del intervalo; traer páginas más antiguas no lo soluciona. Hay que verificar por separado el cese de negociación y la cobertura, no eliminar el control para forzar un resultado.

El control es deliberadamente conservador. El mercado puede haber dejado de operar y no contener registros posteriores al límite ni siquiera en una historia completa. Si se acredita el historial completo o el punto de terminación, puede usarse otra prueba de cobertura, distinta de la regla «una página cruza los dos extremos» de este ejemplo.

</details>

## CTF Exchange V2: primero identificar logs, después definir importes

Otra página de Adanech con `taker_only=false` contenía 100 filas y solo 49 hashes distintos. Cuarenta y ocho hashes se repetían, con un máximo de 3 filas por hash. Restar `100 - 49 = 51` y llamar wash trading al resto mezcla filas de respuesta y transacciones de cadena. Quedarse con la primera fila de cada hash también puede borrar registros distintos dentro de una transacción.

El [código fijado de CTF Exchange V2](https://github.com/Polymarket/ctf-exchange-v2/blob/ccc0596074f4dfd62c944fbca4de252893b82b4b/src/exchange/mixins/Trading.sol) explica la jerarquía. Una llamada `matchOrders` procesa una orden taker y un grupo de órdenes maker. Cada ejecución maker emite un `OrderFilled`; al terminar el taker, emite otro `OrderFilled` de resumen e inmediatamente después `OrdersMatched`. Los dos últimos se generan en [`_emitTakerFilledEvents`](https://github.com/Polymarket/ctf-exchange-v2/blob/ccc0596074f4dfd62c944fbca4de252893b82b4b/src/exchange/mixins/Events.sol).

Estas son las cuentas de las tres transacciones inspeccionadas. **La tabla verifica el número de eventos, no que los importes de ambos lados sean iguales.**

| Transacción de Polygon | `OrderFilled` | `OrdersMatched` | Niveles de eventos |
| --- | ---: | ---: | --- |
| [A](https://polygon.blockscout.com/tx/0x3bbc4f9c0363cf8fc1a89031183a6ae2bd9eb972a1d9b59acf87021118e0f5f6) | 2 | 1 | 1 detalle maker + 1 resumen taker |
| [B](https://polygon.blockscout.com/tx/0x243ebb2cddf06e31f85fe3cdcb94a7d839fd029cb4d58e68ede64cbe7a994dc3) | 3 | 1 | 2 detalles maker + 1 resumen taker |
| [C](https://polygon.blockscout.com/tx/0xee9d91888a8109cb284d7c2a9980694738db07534786f985c501ee0b1376bfd8) | 2 | 1 | 1 detalle maker + 1 resumen taker |

Se cuentan registros de órdenes; una cartera puede tener varias. En el resumen taker, `OrderFilled.maker` también contiene `takerOrder.maker`, es decir, el creador de la orden agresora. El nombre del campo `maker` no basta para identificar una ejecución pasiva.

### El intercambio directo del mismo token y el par Yes/No no comparten contabilidad

Un **ejemplo construido de BUY contra SELL del mismo token de resultado** permite ver la duplicación: se compran 10 participaciones a $0.60, ejecutando órdenes de venta de 4 y 6 participaciones. Los detalles maker representan $2.40 y $3.60, y el total taker es $6. Solo al sumar los tres como importes económicos independientes se obtiene el falso $12. No es una reconstrucción monetaria de A/B/C.

Las rutas MINT/MERGE que cruzan Yes y No se calculan de otro modo. En un **MINT construido sin comisiones ni remanentes**, una parte paga $4 por 10 Yes y otra $6 por 10 No; aportan $10 de garantía entre ambas. Son pagos por tokens de resultados diferentes. Que los $6 estén en un resumen taker no los convierte en un duplicado de los $4.

```text
Intercambio directo: detalles maker 2.40 + 3.60 = total taker 6.00
MINT:               pago Yes 4.00 + pago No 6.00 = garantía 10.00
```

En ese MINT puro y simplificado, con unidades alineadas y sin comisiones ni remanentes, el pago de garantía del taker es la garantía total del par menos el pago maker. Las participaciones recibidas se suman en el otro lado. El código real debe identificar también `side`, `tokenId`, precisión de la garantía, comisiones y remanentes. Un detalle engañoso: el enum `COMPLEMENTARY` del código fijado corresponde al BUY/SELL directo del mismo token; las dos compras o dos ventas entre resultados complementarios usan `MINT` o `MERGE`.

**Conserva detalles maker y resúmenes taker en tablas separadas.** Clasificar logs identifica el papel de un registro; definir el importe económico viene después de identificar el tipo de cruce. Contar bien los eventos no garantiza sumar bien los importes.

### Cómo identificar un evento de resumen

Para deduplicar descargas repetidas de eventos originales, usa `(chain_id, transaction_hash, log_index)`. Aquí `log_index` es el **`logIndex` de ámbito de bloque del RPC**. Construye un mapa con esa clave; no lo uses directamente como índice del array `receipt.logs`. Si la base del indexador usa posiciones dentro de cada transacción, utiliza ese sistema de manera coherente, preservando orden original y adyacencia.

Lo siguiente sigue siendo pseudocódigo, limitado a un recibo completo y exitoso y a un emisor confirmado para la ruta V2 fijada. Primero hay que decodificar el ABI y normalizar direcciones y enteros. Si falla cualquier `require`, se detiene la agregación automática de ese recibo.

```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` y `order_fills` solo incluyen el contrato confirmado. Antes de la última línea, todos los `OrderFilled` relevantes deben pertenecer a grupos completos y explicables. Logs ausentes, campos contradictorios o grupos sin cerrar se reservan para investigar. Una transacción puede contener varios cruces; cada grupo se empareja por separado. Si una versión futura rompe la adyacencia, pausa la agregación de ese recibo y comprueba la versión. No uses como alternativa la última fila con el mismo `orderHash` de toda la transacción: una orden puede ejecutarse parcialmente en varias llamadas. Ambas tablas conservan sus importes, que después se interpretan según intercambio directo, MINT o MERGE.

## Las reversiones rápidas dependen del umbral

Apliqué la misma regla en ambas ventanas: agrupar por identificador de cartera proxy y token de resultado, y buscar compras y ventas opuestas cuya cantidad difiera como máximo un 2%. Cada registro solo puede participar en un par. Cambiar únicamente el tiempo máximo produce resultados muy distintos.

| Separación máxima | Adanech: 83 registros de la ventana | Ciucă: 128 registros de la ventana |
| --- | ---: | ---: |
| 30 segundos | 1 par | 2 pares |
| 60 segundos | 2 pares | 37 pares |
| 5 minutos | 2 pares | 39 pares |
| 15 minutos | 19 pares | 39 pares |
| 60 minutos | 20 pares | 44 pares |

Con 15 minutos, los registros seleccionados representan **8.39% y 2.50%** de los importes de sus ventanas. Son candidatos para investigar, no operaciones ficticias ya identificadas.

Un contraejemplo construido muestra la diferencia: una cuenta de creación de mercado compra 100 No a $0.985 y nueve segundos después vende lo mismo a $0.986, con $0.10 de diferencia antes de comisiones. Un ajuste normal de inventario y un ciclo deliberado pueden cumplir las mismas condiciones de poco tiempo, cantidad parecida y dirección opuesta.

Faltan relaciones entre contrapartes, conexiones de fondos entre carteras, comisiones y cambios de inventario. Este filtro de reversión dentro de una cartera tiene condiciones distintas al análisis de «cuentas emparejadas operando repetidamente entre sí» que cita WIRED.

## Los precios de cinco minutos cambiaron la primera interpretación

El 22 de septiembre de 2026, varios puntos consecutivos de doce horas del contrato Yes de Adanech eran `0.0030`. Al principio solo se veía «siguen las operaciones, pero el precio de la muestra gruesa no cambia». Los datos de cinco minutos hicieron incompleta esa lectura.

La serie completa contiene 288 intervalos normales de cinco minutos. Comparar puntos consecutivos detecta tres cambios de precio. La tabla muestra únicamente **la referencia previa y el inicio de los tres cambios**, omitiendo intervalos sin variación; no son cuatro muestras consecutivas.

| Hora UTC | Precio histórico del intervalo Yes |
| --- | ---: |
| 20:25 | 0.0030 |
| 20:30 | 0.0020 |
| 20:35 | 0.0025 |
| 21:00 | 0.0030 |

La mayor resolución revela la bajada y recuperación que ocultaba el muestreo grueso. Sigue siendo un precio por intervalo; atribuir un impacto causal exige cotizaciones, profundidad y tiempos de información contemporáneos.

La consulta de este contrato liquidado también añadió al final un punto de pago con `resolution_seconds=0, price=0`. Según la [convención de la API](https://data-api.polymarket.com/v2/docs), debe separarse de los puntos normales de cinco minutos y verificar la liquidación en [resolutions](https://data-api.polymarket.com/v2/resolutions?condition=0x7d0aaf81bbd3fd73b6a1651cce08a452c0cbf9c0cbb4520ce0f981065b639d88). Si se mezcla con la serie de mercado, dibuja una caída ficticia a cero.

Para curvas de precios o análisis de señales puede empezarse por los históricos, sin reconstruir cada `OrderFilled` en cadena. Filtrar el punto de pago es solo un paso: también hay que comprobar cobertura, intervalos ausentes y liquidación. Para estimar rentabilidad realizable se necesitan además hipótesis de ejecución, comisiones y deslizamiento. Un precio histórico por intervalo no es una cotización ejecutable.

El orden reutilizable es **alinear contratos, tiempos y denominadores; separar Yes/No de BUY/SELL; identificar logs y elegir la definición monetaria según el tipo de cruce**. Lo mismo vale para estudios externos: recalcular una tabla y reconstruir un clasificador son tareas distintas. Conservar la evidencia de cada nivel resulta más útil que empezar discutiendo un porcentaje. En backtests de acciones aparece un problema similar: [¿Es fiable la tasa de acierto si se selecciona repetidamente la misma acción?](/es/posts/quant-winrate-repeated-stock-picks/) compara tres definiciones con Python y SQL completos.

*Este texto trata definiciones de datos e implementación, no ofrece recomendaciones de negociación. Los importes Gamma y las ventanas locales proceden de instantáneas del 2026-10-20 UTC; la tabla MSR, el contrato de API y el código fijado se revisaron el 2026-10-21 UTC.*
