Polymarket Bot Tutorial · Capítulo 12 de 32
Como detectar phantom fills da Polymarket (ordens que parecem executadas, mas não são), implementar retries idempotentes, distinguir status=matched de rested-on-book e sobreviver a falhas transitórias.
O que este capítulo cobre
Um phantom fill é um modo de falha específico da Polymarket em que o CLOB reconhece um match, mas a chain ainda não confirmou a transferência de ERC-1155. Uma ordem subsequente em cerca de 5 segundos é rejeitada com um erro enganoso de "balance: 0". A solução é idempotência e uma espera por settlement. Este capítulo é o playbook de produção que pagamos com dinheiro real.
- O que é um phantom fill
- status=matched vs status=delayed vs status=posted
- Padrão de polling: consulte o status antes de comemorar
- FOK como anti-phantom-fill
- Retries idempotentes com client_order_id
- Incidente real em produção: como corrigimos o nosso
- Código: padrão detect-then-act após a ordem
O que é um phantom fill
Um phantom fill é quando a API do CLOB responde à sua ordem com status: "matched", mas a transferência on-chain de ERC-1155 ainda não foi liquidada. O matcher do CLOB é mais rápido do que a produção de blocos da Polygon (~2s por bloco). Por cerca de 2 a 5 segundos após o match na API, sua wallet ainda não possui on-chain os tokens que o matcher diz que você tem.
O bug no bot aparece quando uma ação subsequente - normalmente uma venda GTC para postar o take-profit - roda dentro dessa janela. O CLOB verifica o saldo na chain, vê zero e rejeita com not enough balance / allowance: balance: 0, order amount: N. A mensagem de erro culpa a allowance; a causa é o atraso de settlement.
Na primeira vez que isso acontece, você assume que é um bug de allowance e perde uma hora. A solução é simples: esperar, verificar e então postar.
status=matched vs status=delayed vs status=posted
A resposta de colocação da ordem inclui um campo status com três valores que importam.
matched: a ordem casou imediatamente com o book. O inventário vai liquidar em 2 a 5 segundos. É isso que FOK/FAK retornam quando têm sucesso.delayed: o matcher não conseguiu liquidar de forma síncrona e enfileirou o match. Raro; normalmente indica congestionamento. Trate comomatchedpara fins do padrão de wait + verify.posted(também chamado delive): a ordem ficou resting no book sem execução. Retornado por ordens GTC que não casaram imediatamente. O inventário não é afetado; nenhuma ação subsequente é necessária ainda.
A regra de decisão: se o status for matched ou delayed, não coloque nenhuma ação subsequente que dependa do novo inventário até verificar a transferência on-chain.
Padrão de polling: consulte o status antes de comemorar
O padrão de verificação: depois de um match bem-sucedido, faça polling do saldo de CTF até ele refletir os novos tokens; então prossiga.
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 a 5 segundos em boas condições de rede, até 15s durante congestionamento na Polygon. Uma espera de 5 segundos cobre 95% dos casos; em produção, defina o timeout para 15s e gere alerta em caso de timeout.
Para bots de alta frequência que não podem bloquear, uma alternativa é a assinatura de eventos: monitore o evento TransferSingle do CTF para seu endereço de proxy e dispare ações downstream no recebimento. Isso desloca a espera para uma fila em vez de bloquear o loop da estratégia.
FOK como anti-phantom-fill
Escolher FOK em vez de FAK é uma defesa parcial contra a bagunça de phantom-fill. FOK executa a ordem inteira ou retorna cancelled; FAK pode retornar filled_size parcial. Quando uma execução parcial é seguida por uma venda GTC dimensionada para a ordem original, a venda falha por atraso de settlement mais incompatibilidade de tamanho - dois bugs que se acumulam.
Com FOK, o tamanho é binário: ou o tamanho total casou ou nada aconteceu. A lógica de postagem subsequente sempre sabe o que esperar.
Isso não elimina a necessidade da espera - até um match FOK perfeito está sujeito à janela de settlement de 2 a 5 segundos. Mas remove uma classe de divergência de bookkeeping.
Retries idempotentes com client_order_id
Falhas de rede durante a colocação da ordem criam um cenário pior caso: a chamada HTTP do bot expirou, mas a ordem pode ou não ter sido recebida. Tentar novamente de forma ingênua pode duplicar a ordem; não tentar novamente pode perder uma posição.
A correção é o campo client_order_id na colocação da ordem. Gere um UUID determinístico por ordem pretendida; se o servidor já viu esse ID antes, ele retorna o status da ordem existente em vez de criar uma duplicata.
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")
O padrão: gere o ID primeiro, faça retry em falha de transporte, nunca em rejeição lógica. A deduplicação do lado do servidor é por API key e dura cerca de 5 minutos.
Incidente real em produção: como corrigimos o nosso
Do nosso próprio diário de produção, maio de 2025. Uma janela de 60 minutos em que o trader bot colocou 22 ordens de compra, todas matched, mas apenas 14 vendas GTC foram aceitas. Oito posições ficaram sem saída postada.
Causa raiz: o bot postava a venda GTC dentro de 800ms após o match da compra, muito antes de a chain confirmar a transferência de ERC-1155. O CLOB rejeitava com a mensagem "balance: 0"; o bot registrava o erro, mas não fazia retry. Oito posições seguiram silenciosamente até a resolução sem proteção de take-profit. Três encerraram fora do dinheiro; uma encerrou em 0,99 por sorte.
A correção foi publicada como uma espera bloqueante de 5 segundos entre qualquer execução de compra e qualquer postagem GTC no mesmo token. Validado com 30 paper trades mais 30 live trades; zero erros de balance-zero desde então.
A lição: um caminho de erro silencioso custa mais caro do que um barulhento. Depois disso, fizemos todos os erros de phantom-fill dispararem um alerta no Telegram, para que qualquer drift futuro fosse visível em segundos.
Código: padrão detect-then-act após a ordem
Padrão de compra e depois postagem em produção.
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}
O padrão sobrevive aos modos de falha comuns: phantom-fill, queda transitória de rede, tamanho GTC abaixo do mínimo. Retorna informação suficiente para que a camada de estratégia decida o que retry, logar ou alertar.














