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 comomatchedpara efectos del patrón de esperar + verificar.posted(también llamadolive): 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.














