Polymarket Bot Tutorial · Hoofdstuk 12 van 32

Hoe Polymarket phantom fills te detecteren (orders die er gevuld uit lijken te zien maar dat niet zijn), idempotente retries implementeren, status=matched onderscheiden van rested-on-book en transient failures overleven.

Wat dit hoofdstuk behandelt

Een phantom fill is een Polymarket-specifieke failure mode waarin de CLOB een match bevestigt maar de chain de ERC-1155 transfer nog niet heeft bevestigd. Een vervolg-order binnen ~5 seconden wordt afgewezen met een misleidende "balance: 0" fout. De genezing is idempotentie en een settlement-wacht. Dit hoofdstuk is het productie-playbook waarvoor we met echt geld hebben betaald.

Dit is hoofdstuk 12 van onze 32-delige serie over het bouwen van een Polymarket trading bot. We behandelen het onderwerp in detail in de secties hieronder. De body content voor elke sectie wordt geschreven en hoofdstuk-per-hoofdstuk uitgerold; FAQ-antwoorden en referenties zijn al compleet en weerspiegelen production-ervaring van het draaien van onze eigen trader.

  • Wat een phantom fill is
  • status=matched vs status=delayed vs status=posted
  • Polling-patroon: poll status voor je viert
  • FOK als anti-phantom-fill
  • Idempotente retries met client_order_id
  • Echte productie-incident: hoe we de onze fixten
  • Code: detect-then-act post-order patroon

Wat een phantom fill is

Een phantom fill is wanneer de CLOB API reageert op je order met status: "matched" maar de on-chain ERC-1155 transfer nog niet is gesettled. De CLOB matcher is sneller dan Polygon block production (~2s per block). Voor ongeveer 2-5 seconden na de API-match houdt je wallet on-chain niet de tokens die de matcher zegt dat je bezit.

De bot-bug emergeert wanneer een vervolg-actie — typisch een GTC sell om take-profit te posten — binnen dat venster draait. De CLOB checkt de chain-balance, ziet nul en weigert met not enough balance / allowance: balance: 0, order amount: N. De foutmelding beschuldigt de allowance; de oorzaak is settlement-lag.

De eerste keer dat dit gebeurt neem je aan dat het een allowance-bug is en verspil je een uur. De genezing is simpel: wacht, verifieer, post dan.

status=matched vs status=delayed vs status=posted

De order-placement respons bevat een status-veld met drie waarden die tellen.

  • matched: de order matched direct tegen het book. Inventory zal in 2-5 seconden settlen. Dit is wat FOK/FAK teruggeven bij succes.
  • delayed: de matcher kon niet synchroon settlen en zette de match in queue. Zeldzaam; meestal indiceert congestie. Behandel als matched voor het doel van het wait + verify patroon.
  • posted (ook live genoemd): de order rust ongevuld op het book. Teruggegeven door GTC orders die niet direct matchten. Inventory is niet beïnvloed; nog geen vervolg-actie nodig.

De beslis-regel: als status matched of delayed is, plaats geen vervolg dat de nieuwe inventory vereist totdat je de chain-transfer hebt geverifieerd.

Polling-patroon: poll status voor je viert

Het verificatie-patroon: na een succesvolle match, poll de CTF-balance totdat hij de nieuwe tokens reflecteert, ga dan door.

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

Typisch settlement: 2-5 seconden in goede netwerk-condities, tot 15s tijdens Polygon-congestie. Een 5-seconden wacht dekt 95% van de gevallen; zet voor productie de timeout op 15s en alert bij timeout.

Voor high-frequency bots die zich het blocken niet kunnen veroorloven, is een alternatief event-subscription: kijk naar het TransferSingle event van de CTF voor je proxy-adres en trigger downstream acties bij ontvangst. Dit duwt de wacht naar een queue in plaats van de strategy-loop te blocken.

FOK als anti-phantom-fill

FOK kiezen boven FAK is een partiële verdediging tegen phantom-fill chaos. FOK vult ofwel de hele order of geeft cancelled terug; FAK kan een partiële filled_size teruggeven. Wanneer een partiële fill wordt gevolgd door een GTC sell gedimensioneerd op de originele order, faalt de sell op settlement-lag plus size-mismatch — twee compounderende bugs.

Met FOK is de size binair: ofwel de volledige size matched of niets. De vervolg-posting logica weet altijd wat te verwachten.

Dit elimineert niet de noodzaak voor de wacht — zelfs een perfecte FOK match is onderhevig aan het 2-5 seconde settlement-venster. Maar het verwijdert één klasse van boekhouding-divergentie.

Idempotente retries met client_order_id

Netwerk-failures tijdens order-plaatsing creëren een worst-case scenario: de HTTP-call van de bot ging timeout, maar de order kan al dan niet ontvangen zijn. Naïef retryen kan dubbel-plaatsen; niet retryen kan een positie missen.

De fix is het client_order_id veld op order-plaatsing. Genereer een deterministische UUID per bedoelde order; als de server die ID eerder heeft gezien, geeft hij de status van de bestaande order terug in plaats van een duplicaat te creëren.

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

Het patroon: genereer de ID eerst, retry op transport-failure, nooit op logische rejection. De server-side dedup is per-API-key, duurt ~5 minuten.

Echte productie-incident: hoe we de onze fixten

Uit ons eigen productie-diary, mei 2025. Een venster van 60 minuten waarin de trader-bot 22 buy orders plaatste, allemaal matched, maar slechts 14 GTC sells werden geaccepteerd. Acht posities hadden geen exit gepost.

Root cause: de bot postte de GTC sell binnen 800ms van de buy-match, ruim voordat de chain de ERC-1155 transfer bevestigde. De CLOB weigerde met de "balance: 0" boodschap; de bot logde de fout maar deed geen retry. Acht posities reden stil tot resolution zonder take-profit bescherming. Drie sloten out of the money; één sloot op 0,99 door geluk.

De fix shipte als een 5-seconde blocking wacht tussen elke buy-fill en elke GTC post op dezelfde token. Geverifieerd via 30 paper trades plus 30 live trades; nul balance-zero fouten sindsdien.

De les: een stil foutpad is duurder dan een luid pad. Hierna lieten we alle phantom-fill fouten een Telegram alert triggeren, zodat een toekomstige drift-modus binnen seconden zichtbaar zou zijn.

Code: detect-then-act post-order patroon

Productie buy-then-post patroon.

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}

Het patroon overleeft de gangbare failure modes: phantom-fill, transient netwerk-drop, under-minimum GTC size. Geeft genoeg informatie terug voor de strategy-laag om te beslissen wat te retryen vs loggen vs alerten.

Veelgestelde vragen

Wat is een phantom fill op Polymarket?
Een phantom fill is wanneer je bot gelooft dat een order vulde maar de exchange registreert hem als nog niet (of partieel) gevuld. Het gebeurt wanneer client-code een HTTP 200 respons behandelt als bevestiging, terwijl de respons alleen betekent dat de order werd geaccepteerd in de matching engine. We leerden dit op de harde manier — commits 06deaef, 8bb7761 en e68a087 in onze trader-historie fixen precies dit.
Hoe vermijd ik phantom fills?
Drie regels: (1) Gebruik FOK orders voor buys — de order is ofwel volledig gevuld of volledig weg, nooit ambigu. (2) Behandel elke non-matched status als niet gevuld — poll de order-status tot status=matched OF amount_filled > 0. (3) Gebruik een client_order_id (clientOrderId in V2) voor idempotentie zodat retries niet dubbel vullen.
Wat betekent status=delayed?
De order zit in de matching engine maar is nog niet volledig gematched. Hij kan binnen seconden matchen of kan rusten. Poll altijd — als status delayed blijft voor meer dan 5-10 seconden en amount_filled is 0, behandel als ongevuld en overweeg cancelen.
Hoe retry ik veilig zonder dubbel te vullen?
Genereer een uniek client_order_id per logische trade-poging en geef het mee op elke retry. De exchange dedupt op client_order_id zodat een geretryede order met hetzelfde id wordt geweigerd als duplicaat in plaats van opnieuw geplaatst. Implementaties: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Kan ik een 200 OK respons van het order endpoint vertrouwen?
Nee — een 200 OK betekent alleen "je request werd geaccepteerd door de matching engine", niet "je order werd gevuld". Je moet de order-status pollen op orderId na submission en alleen status=matched (of amount_filled > 0) als echte fill behandelen.
Wat als mijn bot crasht tussen het sturen van een order en het zien van de respons?
Bij restart, query open orders en recente fills via de SDK. Reconcile tegen je lokale diary/state — als je een order verstuurde op tijd T maar er geen record bestaat, query orders sinds T en match op client_order_id. Als nog steeds ontbrekend, bereikte de order nooit de matching engine en kun je veilig opnieuw sturen.