Tutorial de Bot de Polymarket · Capítulo 12 de 32

Cómo detectar phantom fills en Polymarket (órdenes que parecen ejecutadas pero no lo están), implementar reintentos idempotentes, distinguir status=matched de rested-on-book y sobrevivir a fallas transitorias.

Qué cubre este capítulo

Un phantom fill es un modo de falla específico de Polymarket en el que el CLOB reconoce un match pero la cadena aún no ha confirmado la transferencia ERC-1155. Una orden de seguimiento dentro de ~5 segundos es rechazada con un engañoso error "balance: 0". La solución es la idempotencia y una espera de settlement. Este capítulo es el playbook de producción por el que pagamos con dinero real.

  • Qué es un phantom fill
  • status=matched vs status=delayed vs status=posted
  • Patrón de polling: consultar el status antes de celebrar
  • FOK como anti-phantom-fill
  • Reintentos idempotentes con client_order_id
  • Incidente real de producción: cómo solucionamos el nuestro
  • Código: patrón detect-then-act post-order

Qué es un phantom fill

Un phantom fill ocurre cuando la API del CLOB responde a tu orden con status: "matched" pero la transferencia ERC-1155 on-chain todavía no se ha liquidado. El matcher del CLOB es más rápido que la producción de bloques en Polygon (~2s por bloque). Durante aproximadamente 2-5 segundos después del match de la API, tu wallet todavía no tiene on-chain los tokens que el matcher dice que posees.

El bug del bot aparece cuando una acción posterior - normalmente una venta GTC para publicar el take-profit - se ejecuta dentro de esa ventana. El CLOB revisa el saldo en la cadena, ve cero y rechaza con not enough balance / allowance: balance: 0, order amount: N. El mensaje de error culpa al allowance; la causa real es el retraso de settlement.

La primera vez que esto pasa, asumes que es un bug de allowance y pierdes una hora. La solución es simple: esperar, verificar y luego publicar.

status=matched vs status=delayed vs status=posted

La respuesta al colocar la orden incluye un campo status con tres valores importantes.

  • matched: la orden hizo match inmediatamente contra el book. El inventario se liquidará en 2-5 segundos. Esto es lo que devuelven FOK/FAK cuando salen bien.
  • delayed: el matcher no pudo liquidar de forma síncrona y puso el match en cola. Es raro; normalmente indica congestión. Trátalo como matched para efectos del patrón de esperar + verificar.
  • posted (también llamado live): la orden quedó resting on the book sin ejecutarse. Lo devuelven órdenes GTC que no hicieron match de inmediato. El inventario no se ve afectado; todavía no hace falta ninguna acción posterior.

La regla de decisión: si el status es matched o delayed, no coloques ningún seguimiento que requiera el nuevo inventario hasta haber verificado la transferencia on-chain.

Patrón de polling: consultar el status antes de celebrar

El patrón de verificación: después de un match exitoso, consulta el balance de CTF hasta que refleje los nuevos tokens y luego continúa.

def wait_for_settlement(token_id, expected_size, timeout=15):
    """Block until on-chain balance reaches expected_size or timeout."""
    start = time.time()
    while time.time() - start < timeout:
        bal = ctf_contract.functions.balanceOf(PROXY, token_id).call()
        if bal >= expected_size:
            return True
        time.sleep(0.5)
    return False

Settlement típico: 2-5 segundos en buenas condiciones de red, hasta 15s durante congestión en Polygon. Una espera de 5 segundos cubre el 95% de los casos; para producción, fija el timeout en 15s y genera una alerta si expira.

Para bots de alta frecuencia que no pueden darse el lujo de bloquear, una alternativa es la suscripción a eventos: monitorea el evento TransferSingle del CTF para tu dirección proxy y dispara las acciones downstream al recibirlo. Esto mueve la espera a una cola en lugar de bloquear el loop de la estrategia.

FOK como anti-phantom-fill

Elegir FOK en lugar de FAK es una defensa parcial contra el caos de phantom fills. FOK llena toda la orden o devuelve cancelled; FAK puede devolver un filled_size parcial. Cuando un fill parcial va seguido de una venta GTC dimensionada al tamaño original, la venta falla por retraso de settlement más desajuste de tamaño: dos bugs que se acumulan.

Con FOK, el tamaño es binario: o hizo match el tamaño completo o no pasó nada. La lógica de publicación posterior siempre sabe qué esperar.

Esto no elimina la necesidad de esperar - incluso un match perfecto de FOK sigue sujeto a la ventana de settlement de 2-5 segundos. Pero sí elimina una clase de divergencia en el bookkeeping.

Reintentos idempotentes con client_order_id

Las fallas de red durante la colocación de órdenes crean el peor escenario: la llamada HTTP del bot expiró, pero la orden pudo o no haber sido recibida. Reintentar ingenuamente puede duplicar la orden; no reintentar puede hacerte perder una posición.

La solución es el campo client_order_id en la colocación de órdenes. Genera un UUID determinístico por cada orden prevista; si el servidor ya vio ese ID antes, devuelve el status de la orden existente en lugar de crear un duplicado.

import uuid
oid = str(uuid.uuid4())   # generate once, retry with same value
for attempt in range(3):
    try:
        resp = c.create_and_post_order(args, OrderType.FOK, client_order_id=oid)
        return resp
    except (TimeoutError, ConnectionError):
        time.sleep(0.5 * (2 ** attempt))
raise RuntimeError("post failed after 3 attempts")

El patrón: genera el ID primero, reintenta ante fallas de transporte, nunca ante un rechazo lógico. La deduplicación del lado servidor es por API key y dura ~5 minutos.

Incidente real de producción: cómo solucionamos el nuestro

De nuestro propio diario de producción, mayo de 2025. Una ventana de 60 minutos en la que el bot trader colocó 22 órdenes de compra, todas hicieron match, pero solo 14 ventas GTC fueron aceptadas. Ocho posiciones se quedaron sin exit publicado.

Causa raíz: el bot publicó la venta GTC dentro de los 800ms posteriores al match de compra, mucho antes de que la cadena confirmara la transferencia ERC-1155. El CLOB rechazó con el mensaje "balance: 0"; el bot registró el error pero no reintentó. Ocho posiciones quedaron expuestas hasta la resolución sin protección de take-profit. Tres cerraron fuera del dinero; una cerró en 0.99 por pura suerte.

La corrección se desplegó como una espera bloqueante de 5 segundos entre cualquier fill de compra y cualquier publicación GTC sobre el mismo token. Verificado con 30 paper trades más 30 live trades; desde entonces, cero errores de balance cero.

La lección: una ruta de error silenciosa cuesta más que una ruidosa. Después de esto hicimos que todos los errores de phantom fill dispararan una alerta de Telegram, para que cualquier deriva futura fuera visible en segundos.

Código: patrón detect-then-act post-order

Patrón de compra y luego publicación en producción.

def buy_then_post_tp(token_id, size, buy_price, tp_price):
    # 1. Place FOK buy
    buy_args = OrderArgs(token_id=token_id, price=buy_price, size=size, side="BUY")
    buy_resp = c.create_and_post_order(buy_args, OrderType.FOK)
    if buy_resp.status != "matched":
        return {"ok": False, "stage": "buy", "reason": buy_resp.status}

    # 2. Wait for on-chain settlement (5s sane default; bump for congestion)
    if not wait_for_settlement(token_id, expected_size=size, timeout=15):
        return {"ok": False, "stage": "settle", "reason": "timeout"}

    # 3. Confirm minimum size for GTC
    if size < 5:
        # GTC won't accept; fall back to ride-to-resolve or FOK sell at TP later
        return {"ok": True, "stage": "buy_only", "note": "size<5, no GTC posted"}

    # 4. Post GTC sell
    sell_args = OrderArgs(token_id=token_id, price=tp_price, size=size, side="SELL")
    sell_resp = c.create_and_post_order(sell_args, OrderType.GTC)
    return {"ok": sell_resp.status in ("posted","live"), "buy": buy_resp, "sell": sell_resp}

El patrón sobrevive a los modos de falla comunes: phantom-fill, caída transitoria de red, tamaño GTC por debajo del mínimo. Devuelve suficiente información para que la capa de estrategia decida qué reintentar, qué registrar y qué alertar.

Preguntas frecuentes

¿Qué es un phantom fill en Polymarket?
Un phantom fill es cuando tu bot cree que una orden se ejecutó pero el exchange la registra como todavía no ejecutada (o parcialmente ejecutada). Pasa cuando el código cliente trata una respuesta HTTP 200 como confirmación, cuando en realidad la respuesta solo significa que la orden fue aceptada por el motor de matching. Lo aprendimos por las malas: los commits 06deaef, 8bb7761 y e68a087 en nuestro historial de trader corrigen exactamente esto.
¿Cómo evito los phantom fills?
Tres reglas: (1) Usa órdenes FOK para compras: la orden se llena por completo o desaparece por completo, nunca queda ambigua. (2) Trata cualquier status que no sea matched como no ejecutado: consulta el status de la orden hasta que status=matched O amount_filled > 0. (3) Usa un client_order_id (clientOrderId en V2) para idempotencia, de modo que los reintentos no dupliquen la ejecución.
¿Qué significa status=delayed?
La orden está en el motor de matching pero todavía no se ha ejecutado por completo. Podría hacer match en segundos o podría quedarse resting. Siempre haz polling: si el status se mantiene delayed por más de 5-10 segundos y amount_filled es 0, trátala como no ejecutada y considera cancelarla.
¿Cómo reintento de forma segura sin duplicar ejecuciones?
Genera un client_order_id único por cada intento lógico de trade y pásalo en cada reintento. El exchange hace dedupe por client_order_id, así que una orden reintentada con el mismo id se rechaza como duplicada en lugar de colocarse otra vez. Implementaciones: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
¿Puedo confiar en una respuesta 200 OK del endpoint de órdenes?
No: un 200 OK solo significa "tu solicitud fue aceptada por el motor de matching", no "tu orden se ejecutó". Debes consultar el status de la orden por orderId después de enviarla y solo tratar status=matched (o amount_filled > 0) como una ejecución real.
¿Qué pasa si mi bot se cae entre enviar una orden y ver la respuesta?
Al reiniciar, consulta órdenes abiertas y fills recientes vía el SDK. Reconcílialo con tu diario/estado local: si enviaste una orden en el tiempo T pero no existe registro, consulta las órdenes desde T y haz match por client_order_id. Si sigue sin aparecer, la orden nunca llegó al motor de matching y puedes reenviarla con seguridad.