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 comematchedai fini del pattern wait + verify.posted(chiamato anchelive): 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.











