Polymarket Bot Tutorial · Capitolo 12 di 32

Come rilevare i phantom fill di Polymarket (ordini che sembrano riempiti ma non lo sono), implementare retry idempotenti, distinguere status=matched da rested-on-book e sopravvivere a fallimenti transitori.

Cosa copre questo capitolo

Un phantom fill è una modalità di fallimento specifica di Polymarket in cui il CLOB conferma un match ma la chain non ha ancora confermato il trasferimento ERC-1155. Un ordine successivo entro ~5 secondi viene rifiutato con un errore ingannevole "balance: 0". La cura è idempotenza più attesa di settlement. Questo capitolo è il playbook di produzione che abbiamo pagato con denaro reale.

Questo è il capitolo 12 della nostra serie in 32 parti sulla costruzione di un trading bot per Polymarket. Trattiamo l'argomento in profondità nelle sezioni qui sotto. Il corpo di ogni sezione viene scritto e rilasciato capitolo dopo capitolo; le risposte FAQ e i riferimenti sono già completi e riflettono l'esperienza di produzione del nostro trader interno.

  • Cos'è un phantom fill
  • status=matched vs status=delayed vs status=posted
  • Pattern di polling: controlla lo status prima di festeggiare
  • FOK come anti-phantom-fill
  • Retry idempotenti con client_order_id
  • Incidente reale di produzione: come abbiamo sistemato il nostro
  • Codice: pattern detect-then-act post-ordine

Cos'è un phantom fill

Un phantom fill è quando l'API CLOB risponde al tuo ordine con status: "matched" ma il trasferimento ERC-1155 on-chain non si è ancora regolato. Il matcher CLOB è più veloce della produzione di blocchi Polygon (~2s per blocco). Per circa 2-5 secondi dopo il match API, il tuo wallet on-chain non detiene i token che il matcher dice che possiedi.

Il bug del bot emerge quando un'azione successiva — tipicamente un GTC sell per piazzare il take-profit — gira entro quella finestra. Il CLOB controlla il balance on-chain, vede zero e rifiuta con not enough balance / allowance: balance: 0, order amount: N. Il messaggio di errore accusa l'allowance; la causa è il ritardo di settlement.

La prima volta che succede assumi che sia un bug di allowance e perdi un'ora. La cura è semplice: aspetta, verifica, poi piazza.

status=matched vs status=delayed vs status=posted

La risposta al piazzamento dell'ordine include un campo status con tre valori che contano.

  • matched: l'ordine ha matchato contro il book immediatamente. L'inventario si regolerà in 2-5 secondi. Questo è ciò che restituiscono FOK/FAK quando hanno successo.
  • delayed: il matcher non è riuscito a regolare sincronamente e ha messo in coda il match. Raro; di solito indica congestione. Trattalo come matched ai fini del pattern wait + verify.
  • posted (chiamato anche live): l'ordine sta sul book senza essere riempito. Restituito da ordini GTC che non hanno matchato immediatamente. L'inventario non è influenzato; nessuna azione successiva necessaria ancora.

La regola di decisione: se lo status è matched o delayed, non piazzare alcun ordine successivo che richieda il nuovo inventario finché non hai verificato il trasferimento on-chain.

Pattern di polling: controlla lo status prima di festeggiare

Il pattern di verifica: dopo un match riuscito, fai polling del balance CTF finché non riflette i nuovi token, poi procedi.

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 tipico: 2-5 secondi in buone condizioni di rete, fino a 15s durante congestione di Polygon. Un'attesa di 5 secondi copre il 95% dei casi; per la produzione imposta il timeout a 15s e fai alert su timeout.

Per bot ad alta frequenza che non possono permettersi di bloccare, un'alternativa è l'event-subscription: ascolta l'evento TransferSingle del CTF per il tuo proxy address e scatena le azioni a valle alla ricezione. Questo sposta l'attesa in coda invece di bloccare il loop della strategia.

FOK come anti-phantom-fill

Scegliere FOK invece di FAK è una difesa parziale contro il caos dei phantom fill. FOK riempie l'intero ordine o restituisce cancelled; FAK può restituire un filled_size parziale. Quando un fill parziale è seguito da un GTC sell dimensionato sull'ordine originale, il sell fallisce per ritardo di settlement più mismatch di size — due bug che si compongono.

Con FOK, la size è binaria: o tutta la size ha matchato o niente. La logica di post-ordine sa sempre cosa aspettarsi.

Questo non elimina la necessità dell'attesa — anche un match FOK perfetto è soggetto alla finestra di settlement di 2-5 secondi. Ma rimuove una classe di divergenze contabili.

Retry idempotenti con client_order_id

I fallimenti di rete durante il piazzamento dell'ordine creano uno scenario peggiore: la chiamata HTTP del bot è andata in timeout, ma l'ordine può o non può essere stato ricevuto. Fare retry naive può raddoppiare il piazzamento; non fare retry può perdere una posizione.

Il fix è il campo client_order_id sul piazzamento dell'ordine. Genera un UUID deterministico per ordine inteso; se il server ha visto quell'ID prima, restituisce lo status dell'ordine esistente invece di crearne un duplicato.

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")

Il pattern: genera l'ID prima, fai retry su fallimento di trasporto, mai su rifiuto logico. La dedup lato server è per API key, dura ~5 minuti.

Incidente reale di produzione: come abbiamo sistemato il nostro

Dal nostro diario di produzione, maggio 2025. Una finestra di 60 minuti in cui il trader bot ha piazzato 22 ordini buy, tutti matchati, ma solo 14 GTC sell sono stati accettati. Otto posizioni non avevano un exit piazzato.

Root cause: il bot piazzava il GTC sell entro 800ms dal match del buy, ben prima che la chain confermasse il trasferimento ERC-1155. Il CLOB rifiutava con il messaggio "balance: 0"; il bot loggava l'errore ma non faceva retry. Otto posizioni hanno cavalcato silenziosamente fino alla resolution senza protezione di take-profit. Tre hanno chiuso out of the money; una ha chiuso a 0,99 per fortuna.

Il fix è stato un'attesa bloccante di 5 secondi tra qualsiasi buy fill e qualsiasi GTC post sullo stesso token. Verificato via 30 trade paper più 30 trade live; zero errori balance-zero da allora.

La lezione: un percorso d'errore silenzioso è più costoso di uno rumoroso. Dopo questo abbiamo fatto in modo che tutti gli errori di phantom-fill scatenassero un alert Telegram, così una futura modalità di drift sarebbe stata visibile in pochi secondi.

Codice: pattern detect-then-act post-ordine

Pattern di produzione buy-then-post.

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}

Il pattern sopravvive alle modalità di fallimento comuni: phantom-fill, drop di rete transitorio, size GTC sotto il minimo. Restituisce abbastanza informazioni allo strategy layer per decidere cosa retry, loggare o alert.

Domande frequenti

Cos'è un phantom fill su Polymarket?
Un phantom fill è quando il tuo bot crede che un ordine sia stato riempito ma l'exchange lo registra come non ancora riempito (o parzialmente). Succede quando il client tratta una risposta HTTP 200 come conferma, mentre la risposta significa solo che l'ordine è stato accettato nel matching engine. L'abbiamo imparato a nostre spese — commit 06deaef, 8bb7761 ed e68a087 nella nostra storia del trader fixano esattamente questo.
Come evito i phantom fill?
Tre regole: (1) Usa ordini FOK per i buy — l'ordine è o completamente riempito o completamente sparito, mai ambiguo. (2) Tratta qualsiasi status non-matched come non riempito — fai polling sullo status finché status=matched O amount_filled > 0. (3) Usa un client_order_id (clientOrderId in V2) per idempotenza così i retry non raddoppiano i fill.
Cosa significa status=delayed?
L'ordine è nel matching engine ma non è stato ancora completamente matchato. Potrebbe matchare entro secondi o potrebbe restare. Fai sempre polling — se lo status resta delayed per più di 5-10 secondi e amount_filled è 0, trattalo come non riempito e considera di cancellarlo.
Come faccio retry in sicurezza senza raddoppiare i fill?
Genera un client_order_id univoco per tentativo logico di trade e passalo a ogni retry. L'exchange fa dedup per client_order_id quindi un ordine ritentato con lo stesso id viene rifiutato come duplicato invece di essere piazzato di nuovo. Implementazioni: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Posso fidarmi di una risposta 200 OK dall'endpoint dell'ordine?
No — un 200 OK significa solo "la tua richiesta è stata accettata dal matching engine", non "il tuo ordine è stato riempito". Devi fare polling dello status dell'ordine via orderId dopo la submission e trattare solo status=matched (o amount_filled > 0) come fill reale.
E se il mio bot crasha tra l'invio di un ordine e la risposta?
Al riavvio, interroga gli ordini aperti e i fill recenti via SDK. Riconcilia contro il tuo diario/stato locale — se hai inviato un ordine al tempo T ma non esiste alcun record, interroga gli ordini da T e fai match per client_order_id. Se ancora mancante, l'ordine non ha mai raggiunto il matching engine e puoi rinviarlo in sicurezza.