Polymarket Bot Tutorial · Chapitre 12 sur 32

Comment détecter les phantom fills sur Polymarket (des ordres qui semblent exécutés mais ne le sont pas), implémenter des retries idempotent, distinguer status=matched de rested-on-book, et survivre aux failures transitoires.

Ce que couvre ce chapitre

Un phantom fill est un mode de failure spécifique à Polymarket où le CLOB accuse réception d'un match, mais la chain n'a pas encore confirmé le transfert ERC-1155. Un ordre de suivi dans les ~5 secondes est rejeté avec une erreur trompeuse "balance: 0". La solution est l'idempotence et un temps d'attente de settlement. Ce chapitre est le playbook de production que nous avons payé avec de l'argent réel.

  • Qu'est-ce qu'un phantom fill
  • status=matched vs status=delayed vs status=posted
  • Polling pattern: poll status before celebrating
  • FOK comme anti-phantom-fill
  • Retries idempotent avec client_order_id
  • Incident de production réel: comment nous avons corrigé le nôtre
  • Code: detect-then-act post-order pattern

Qu'est-ce qu'un phantom fill

Un phantom fill se produit lorsque l'API CLOB répond à votre ordre avec status: "matched" mais que le transfert ERC-1155 on-chain n'a pas encore été settle. Le matcher du CLOB est plus rapide que la production de blocs Polygon (~2s par bloc). Pendant environ 2 à 5 secondes après le match de l'API, votre wallet ne détient pas encore on-chain les tokens que le matcher dit que vous possédez.

Le bug du bot apparaît lorsqu'une action de suivi - généralement une vente GTC pour poster le take-profit - s'exécute pendant cette fenêtre. Le CLOB vérifie le chain balance, voit zéro, et rejette avec not enough balance / allowance: balance: 0, order amount: N. Le message d'erreur accuse l'allowance ; la cause est le settlement lag.

La première fois que cela arrive, on suppose un bug d'allowance et on perd une heure. La solution est simple: attendre, vérifier, puis poster.

status=matched vs status=delayed vs status=posted

La réponse de placement d'ordre inclut un champ status avec trois valeurs importantes.

  • matched: l'ordre a été matché immédiatement contre le book. L'inventory se settle en 2 à 5 secondes. C'est ce que renvoient FOK/FAK en cas de succès.
  • delayed: le matcher n'a pas pu settle de manière synchrone et a mis le match en queue. Rare ; indique généralement une congestion. Traitez-le comme matched pour le pattern wait + verify.
  • posted (aussi appelé live): l'ordre reste sur le book sans être exécuté. Renvoyé par les ordres GTC qui n'ont pas matché immédiatement. L'inventory n'est pas affecté ; aucune action de suivi n'est nécessaire pour l'instant.

La règle de décision: si le status est matched ou delayed, ne placez aucun follow-up qui nécessite le nouvel inventory tant que vous n'avez pas vérifié le chain transfer.

Polling pattern: poll status before celebrating

Le pattern de vérification: après un match réussi, poll le solde CTF jusqu'à ce qu'il reflète les nouveaux tokens, puis continuez.

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 typique: 2 à 5 secondes dans de bonnes conditions réseau, jusqu'à 15s en cas de congestion Polygon. Un délai de 5 secondes couvre 95% des cas ; en production, réglez le timeout sur 15s et déclenchez une alerte en cas de timeout.

Pour les bots à haute fréquence qui ne peuvent pas se permettre de bloquer, une alternative est l'event-subscription: surveillez l'événement TransferSingle du CTF pour votre adresse proxy et déclenchez les actions aval à sa réception. Cela déplace l'attente vers une queue au lieu de bloquer la boucle de stratégie.

FOK comme anti-phantom-fill

Choisir FOK plutôt que FAK est une défense partielle contre le chaos des phantom fills. FOK exécute soit l'ordre en entier, soit renvoie cancelled ; FAK peut renvoyer un filled_size partiel. Lorsqu'un fill partiel est suivi d'une vente GTC dimensionnée sur l'ordre d'origine, la vente échoue à cause du settlement lag plus d'un size mismatch - deux bugs qui se cumulent.

Avec FOK, la taille est binaire: soit la taille complète a matché, soit rien du tout. La logique de posting de suivi sait toujours à quoi s'attendre.

Cela n'élimine pas le besoin d'attendre - même un match FOK parfait est soumis à la fenêtre de settlement de 2 à 5 secondes. Mais cela supprime une classe de divergence de bookkeeping.

Retries idempotent avec client_order_id

Les failures réseau pendant le placement d'ordre créent le pire scénario: l'appel HTTP du bot a timeout, mais l'ordre a peut-être été reçu, ou non. Retenter naïvement peut doubler le placement ; ne pas retry peut faire tomber une position.

La solution est le champ client_order_id lors du placement d'ordre. Générez un UUID déterministe par ordre prévu ; si le serveur a déjà vu cet ID, il renvoie le status de l'ordre existant au lieu d'en créer un doublon.

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

Le pattern: générez l'ID d'abord, retry en cas de failure de transport, jamais en cas de rejection logique. La déduplication côté serveur se fait par API key, pendant environ 5 minutes.

Incident de production réel: comment nous avons corrigé le nôtre

Tiré de notre propre journal de production, mai 2025. Une fenêtre de 60 minutes pendant laquelle le trader bot a placé 22 ordres d'achat, tous matchés, mais seulement 14 ventes GTC ont été acceptées. Huit positions n'avaient aucun exit posté.

Cause racine: le bot a posté la vente GTC dans les 800ms après le match de l'achat, bien avant que la chain confirme le transfert ERC-1155. Le CLOB a rejeté avec le message "balance: 0" ; le bot a loggé l'erreur mais n'a pas retry. Huit positions ont silencieusement été conservées jusqu'à la résolution sans protection take-profit. Trois ont clôturé hors de la monnaie ; une a clôturé à 0.99 par chance.

Le correctif a été déployé sous forme d'un wait bloquant de 5 secondes entre tout fill d'achat et toute publication GTC sur le même token. Vérifié via 30 paper trades plus 30 live trades ; zéro erreur balance-zero depuis.

La leçon: un chemin d'erreur silencieux coûte plus cher qu'un chemin bruyant. Après cela, nous avons fait en sorte que toutes les erreurs de phantom-fill déclenchent une alerte Telegram, afin qu'un futur drift mode soit visible en quelques secondes.

Code: detect-then-act post-order pattern

Production buy-then-post pattern.

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}

Le pattern survit aux modes de failure courants: phantom-fill, coupure réseau transitoire, taille GTC sous le minimum. Il renvoie suffisamment d'informations pour que la stratégie décide quoi retry, logguer ou alerter.

Questions fréquemment posées

Qu'est-ce qu'un phantom fill sur Polymarket ?
Un phantom fill, c'est lorsque votre bot pense qu'un ordre a été exécuté alors que l'exchange l'enregistre comme pas encore exécuté (ou partiellement exécuté). Cela se produit lorsque le code client traite une réponse HTTP 200 comme une confirmation, alors que la réponse signifie seulement que l'ordre a été accepté par le matching engine. Nous l'avons appris à nos dépens - les commits 06deaef, 8bb7761 et e68a087 dans l'historique de notre trader corrigent exactement cela.
Comment éviter les phantom fills ?
Trois règles: (1) Utilisez des ordres FOK pour les achats - l'ordre est soit entièrement exécuté, soit entièrement annulé, jamais ambigu. (2) Traitez tout status non-matched comme non exécuté - poll le status de l'ordre jusqu'à status=matched OU amount_filled > 0. (3) Utilisez un client_order_id (clientOrderId en V2) pour l'idempotence afin que les retries ne double-exécutent pas.
Que signifie status=delayed ?
L'ordre est dans le matching engine mais n'a pas encore été entièrement matché. Il peut matcher en quelques secondes ou rester sur le book. Poll toujours - si le status reste delayed pendant plus de 5 à 10 secondes et que amount_filled est à 0, considérez-le comme non exécuté et envisagez de l'annuler.
Comment retry en toute sécurité sans double-exécution ?
Générez un client_order_id unique par tentative de trade logique et transmettez-le à chaque retry. L'exchange déduplique par client_order_id, donc un ordre retenté avec le même id est rejeté comme doublon plutôt que d'être placé à nouveau. Implémentations: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Puis-je faire confiance à une réponse 200 OK de l'endpoint d'ordre ?
Non - un 200 OK signifie seulement "votre requête a été acceptée par le matching engine", pas "votre ordre a été exécuté". Vous devez poll le status de l'ordre par orderId après l'envoi et ne considérer que status=matched (ou amount_filled > 0) comme un vrai fill.
Que faire si mon bot plante entre l'envoi d'un ordre et la réception de la réponse ?
Au redémarrage, interrogez les open orders et les fills récents via le SDK. Réconciliez avec votre journal / état local - si vous avez envoyé un ordre à l'instant T mais qu'aucun enregistrement n'existe, interrogez les ordres depuis T et faites la correspondance par client_order_id. Si c'est toujours manquant, l'ordre n'a jamais atteint le matching engine et vous pouvez le renvoyer en toute sécurité.