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 como matched para fins do padrão de wait + verify.
  • posted (também chamado de live): 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.

Perguntas frequentes

O que é um phantom fill na Polymarket?
Um phantom fill é quando seu bot acredita que uma ordem foi executada, mas a exchange registra como ainda não executada (ou parcialmente executada). Isso acontece quando o código cliente trata uma resposta HTTP 200 como confirmação, quando a resposta significa apenas que a ordem foi aceita pelo matching engine. Aprendemos isso da pior forma - os commits 06deaef, 8bb7761 e e68a087 na história do nosso trader corrigem exatamente isso.
Como evito phantom fills?
Três regras: (1) Use ordens FOK para compras - a ordem ou executa por inteiro ou desaparece por completo, nunca fica ambígua. (2) Trate qualquer status diferente de matched como não executado - faça polling do status da ordem até status=matched OU amount_filled > 0. (3) Use um client_order_id (clientOrderId em V2) para idempotência, para que retries não dupliquem a execução.
O que significa status=delayed?
A ordem está no matching engine, mas ainda não foi totalmente casada. Ela pode casar em segundos ou pode ficar resting. Sempre faça polling - se o status continuar delayed por mais de 5 a 10 segundos e amount_filled for 0, trate como não executada e considere cancelar.
Como faço retry com segurança sem duplicar execução?
Gere um client_order_id único por tentativa lógica de trade e passe esse mesmo valor em cada retry. A exchange faz dedup por client_order_id, então uma ordem repetida com o mesmo id é rejeitada como duplicata em vez de ser colocada novamente. Implementações: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Posso confiar em uma resposta 200 OK do endpoint de ordem?
Não - um 200 OK significa apenas "sua requisição foi aceita pelo matching engine", não "sua ordem foi executada". Você deve fazer polling do status da ordem por orderId após o envio e tratar apenas status=matched (ou amount_filled > 0) como uma execução real.
E se meu bot travar entre enviar uma ordem e ver a resposta?
Na reinicialização, consulte open orders e fills recentes via SDK. Reconcilie com seu diário/state local - se você enviou uma ordem no tempo T, mas nenhum registro existir, consulte ordens desde T e faça o match por client_order_id. Se ainda estiver ausente, a ordem nunca chegou ao matching engine e você pode reenviá-la com segurança.