Polymarket Bot Tutorial · Kapitel 12 von 32

Wie man Polymarket phantom fills erkennt (Orders, die ausgeführt aussehen, es aber nicht sind), idempotente Retries implementiert, status=matched von rested-on-book unterscheidet und vor transient failures bestehen bleibt.

Was dieses Kapitel abdeckt

Ein phantom fill ist ein Polymarket-spezifischer failure mode, bei dem das CLOB einen Match bestätigt, die chain aber den ERC-1155 transfer noch nicht bestätigt hat. Eine Folge-Order innerhalb von etwa 5 Sekunden wird mit einem irreführenden "balance: 0"-Fehler abgelehnt. Die Lösung sind Idempotence und eine settlement-Wartezeit. Dieses Kapitel ist das Production-Playbook, für das wir mit echtem Geld bezahlt haben.

  • Was ein phantom fill ist
  • status=matched vs status=delayed vs status=posted
  • Polling pattern: status prüfen, bevor man feiert
  • FOK als Anti-phantom-fill
  • Idempotente Retries mit client_order_id
  • Realer Production-Vorfall: wie wir unseren behoben haben
  • Code: detect-then-act post-order pattern

Was ein phantom fill ist

Ein phantom fill liegt vor, wenn die CLOB API auf deine Order mit status: "matched" antwortet, der on-chain ERC-1155 transfer aber noch nicht settled ist. Der CLOB matcher ist schneller als die Polygon block production (ca. 2s pro Block). Für ungefähr 2-5 Sekunden nach dem API-Match hält deine Wallet on-chain die Tokens, die der matcher dir zuschreibt, noch nicht.

Der Bot-Bug entsteht, wenn eine Folgeaktion - typischerweise ein GTC sell, um den take-profit zu platzieren - innerhalb dieses Fensters läuft. Das CLOB prüft den chain balance, sieht null und lehnt mit not enough balance / allowance: balance: 0, order amount: N ab. Die Fehlermeldung schiebt es auf allowance; die Ursache ist settlement lag.

Beim ersten Mal vermutest du einen allowance-Bug und verlierst eine Stunde. Die Lösung ist einfach: warten, verifizieren, dann posten.

status=matched vs status=delayed vs status=posted

Die Order-Platzierungsantwort enthält ein status-Feld mit drei relevanten Werten.

  • matched: Die Order wurde sofort gegen das book gematcht. Das Inventory wird in 2-5 Sekunden settled. Das ist das, was FOK/FAK bei Erfolg zurückgeben.
  • delayed: Der matcher konnte nicht synchron settlen und hat den Match in eine Queue gestellt. Selten; weist meist auf congestion hin. Für das wait + verify pattern wie matched behandeln.
  • posted (auch live genannt): Die Order liegt unfilled im book. Wird von GTC orders zurückgegeben, die nicht sofort gematcht wurden. Das Inventory bleibt unverändert; vorerst ist keine Folgeaktion nötig.

Die Entscheidungsregel: Wenn status matched oder delayed ist, keine Folgeaktion platzieren, die das neue Inventory benötigt, bevor du den chain transfer verifiziert hast.

Polling pattern: status prüfen, bevor man feiert

Das Verifikationsmuster: Nach einem erfolgreichen Match die CTF balance pollen, bis sie die neuen Tokens widerspiegelt, und erst dann fortfahren.

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

Typisches Settlement: 2-5 Sekunden bei guten Netzwerkbedingungen, bis zu 15s bei Polygon congestion. Eine 5-Sekunden-Wartezeit deckt 95% der Fälle ab; in Production den Timeout auf 15s setzen und bei Timeout alarmieren.

Für High-Frequency bots, die nicht blockieren können, ist event-subscription eine Alternative: Das TransferSingle-Event der CTF für deine proxy address beobachten und Folgeaktionen beim Empfang auslösen. So wandert das Warten in eine Queue, statt die Strategy-Loop zu blockieren.

FOK als Anti-phantom-fill

FOK statt FAK zu wählen ist ein teilweiser Schutz gegen das Chaos durch phantom fills. FOK füllt entweder die gesamte Order oder gibt cancelled zurück; FAK kann filled_size zurückgeben, das nur teilweise ist. Wenn auf einen Partial Fill ein GTC sell mit der ursprünglichen Ordergröße folgt, scheitert der Sell wegen settlement lag plus Größen-Mismatch - zwei sich verstärkende Bugs.

Mit FOK ist die Größe binär: Entweder wurde die volle Größe gematcht oder gar nichts. Die Folge-Posting-Logik weiß immer, was zu erwarten ist.

Das beseitigt nicht die Notwendigkeit des Wartens - selbst ein perfekter FOK-Match unterliegt dem 2-5-Sekunden-Settlement-Fenster. Aber es entfernt eine Klasse von Buchhaltungs-Abweichungen.

Idempotente Retries mit client_order_id

Netzwerkfehler während der Order-Platzierung erzeugen ein Worst-Case-Szenario: Der HTTP-Call des Bots ist abgelaufen, aber die Order könnte empfangen worden sein oder auch nicht. Blindes Retries kann doppelt platzieren; gar nicht zu retrien kann eine Position verlieren.

Die Lösung ist das Feld client_order_id bei der Order-Platzierung. Generiere pro geplanter Order eine deterministische UUID; wenn der Server diese ID schon gesehen hat, gibt er den Status der existierenden Order zurück, statt eine Duplikat-Order zu erstellen.

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

Das Muster: Zuerst die ID erzeugen, bei Transportfehlern retryen, nie bei einer logischen Ablehnung. Das serverseitige Dedup gilt pro API-key und hält etwa 5 Minuten.

Realer Production-Vorfall: wie wir unseren behoben haben

Aus unserem eigenen Production-Tagebuch, Mai 2025. Ein 60-Minuten-Fenster, in dem der trader bot 22 Buy Orders platzierte, alle gematcht wurden, aber nur 14 GTC sells akzeptiert wurden. Acht Positionen hatten keinen geposteten Exit.

Ursache: Der Bot postete den GTC sell innerhalb von 800ms nach dem Buy-Match, also lange bevor die chain den ERC-1155 transfer bestätigte. Das CLOB lehnte mit der Meldung "balance: 0" ab; der Bot loggte den Fehler, retried aber nicht. Acht Positionen liefen stillschweigend bis zur Resolution ohne take-profit-Schutz. Drei schlossen out of the money; eine schloss zufällig bei 0.99.

Der Fix wurde als 5-Sekunden-blocking wait zwischen jedem buy fill und jedem GTC post auf demselben Token ausgeliefert. Verifiziert mit 30 paper trades plus 30 live trades; seitdem keine balance-zero errors mehr.

Die Lehre: Ein stiller error path ist teurer als ein lauter. Danach ließen wir alle phantom-fill-Fehler einen Telegram-Alert auslösen, damit ein zukünftiger drift mode innerhalb von Sekunden sichtbar wäre.

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}

Das Muster überlebt die häufigsten failure modes: phantom-fill, transient network drop, unter-minimale GTC size. Es liefert genug Informationen für die Strategy-Ebene, um zu entscheiden, was man retried, was man loggt und was man alarmiert.

Häufig gestellte Fragen

Was ist ein phantom fill auf Polymarket?
Ein phantom fill liegt vor, wenn dein Bot glaubt, eine Order sei ausgeführt worden, die Exchange sie aber als noch nicht ausgeführt (oder nur teilweise ausgeführt) speichert. Das passiert, wenn Client-Code eine HTTP 200-Antwort als Bestätigung behandelt, obwohl die Antwort nur bedeutet, dass die Order in die Matching Engine aufgenommen wurde. Wir haben das auf die harte Tour gelernt - die Commits 06deaef, 8bb7761 und e68a087 in unserer Trader-Historie beheben genau das.
Wie vermeide ich phantom fills?
Drei Regeln: (1) FOK orders für Buys verwenden - die Order ist entweder vollständig ausgeführt oder vollständig weg, nie unklar. (2) Jeden Status außer matched als nicht ausgeführt behandeln - den Order-Status pollen, bis status=matched ODER amount_filled > 0. (3) Eine client_order_id (clientOrderId in V2) für Idempotence verwenden, damit Retries nicht doppelt ausführen.
Was bedeutet status=delayed?
Die Order befindet sich in der Matching Engine, wurde aber noch nicht vollständig gematcht. Sie kann innerhalb von Sekunden matchen oder auch im book liegen bleiben. Immer pollen - wenn status länger als 5-10 Sekunden auf delayed bleibt und amount_filled 0 ist, als nicht ausgeführt behandeln und gegebenenfalls canceln.
Wie retrie ich sicher, ohne doppelt auszuführen?
Für jeden logischen Trade-Versuch eine eindeutige client_order_id erzeugen und bei jedem Retry verwenden. Die Exchange dedupliziert über client_order_id, sodass eine erneut gesendete Order mit derselben ID als Duplikat abgelehnt wird, statt erneut platziert zu werden. Implementierungen: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Kann ich einer 200 OK-Antwort vom Order-Endpunkt vertrauen?
Nein - ein 200 OK bedeutet nur "deine Anfrage wurde von der Matching Engine angenommen", nicht "deine Order wurde ausgeführt". Du musst nach dem Absenden den Order-Status per orderId pollen und nur status=matched (oder amount_filled > 0) als echten Fill behandeln.
Was ist, wenn mein Bot zwischen dem Senden einer Order und dem Erhalt der Antwort abstürzt?
Nach dem Neustart offene Orders und recente fills über das SDK abfragen. Mit deinem lokalen Diary/State abgleichen - wenn du eine Order zu Zeitpunkt T gesendet hast, aber kein Datensatz existiert, Orders seit T abfragen und per client_order_id matchen. Wenn sie immer noch fehlt, hat die Order die Matching Engine nie erreicht und du kannst sie sicher erneut senden.