Polymarket Bot Tutorial · Rozdział 12 z 32

Jak wykrywać phantom fills w Polymarket (zlecenia, które wyglądają na zrealizowane, ale nie są), wdrażać idempotent retries, odróżniać status=matched od rested-on-book i przetrwać transient failures.

Co obejmuje ten rozdział

Phantom fill to specyficzny dla Polymarket failure mode, w którym CLOB potwierdza match, ale chain nie potwierdził jeszcze transferu ERC-1155. Kolejne zlecenie po około 5 sekundach zostaje odrzucone mylącym błędem „balance: 0”. Lekarstwem są idempotence i wait na settlement. Ten rozdział to production playbook, za który zapłaciliśmy prawdziwymi pieniędzmi.

To jest rozdział 12 naszej 32-częściowej serii o budowie trading bota dla Polymarket. Temat omawiamy szczegółowo w sekcjach poniżej. Treść główna dla każdej sekcji jest pisana i publikowana rozdział po rozdziale; odpowiedzi FAQ i references są już kompletne i odzwierciedlają doświadczenie produkcyjne z uruchamiania naszego własnego tradera.

  • Czym jest phantom fill
  • status=matched vs status=delayed vs status=posted
  • Polling pattern: sprawdzaj status, zanim zaczniesz świętować
  • FOK jako anti-phantom-fill
  • Idempotent retries z client_order_id
  • Prawdziwy production incident: jak to naprawiliśmy
  • Code: detect-then-act post-order pattern

Czym jest phantom fill

Phantom fill występuje wtedy, gdy API CLOB odpowiada na zlecenie status: "matched", ale on-chain transfer ERC-1155 nie został jeszcze rozliczony. Matcher w CLOB działa szybciej niż produkcja bloków na Polygon (~2 s na blok). Przez około 2–5 sekund po matchu z API Twój wallet nie ma on-chain tokenów, które według matchera już posiadasz.

Błąd bota pojawia się wtedy, gdy kolejne działanie — zwykle GTC sell ustawiane jako take-profit — uruchamia się w tym oknie. CLOB sprawdza on-chain balance, widzi zero i odrzuca z komunikatem not enough balance / allowance: balance: 0, order amount: N. Komunikat zrzuca winę na allowance; prawdziwą przyczyną jest opóźnienie settlement.

Za pierwszym razem zakładasz, że to bug allowance i tracisz godzinę. Lekarstwem jest prosty schemat: poczekaj, zweryfikuj, potem postuj.

status=matched vs status=delayed vs status=posted

Odpowiedź na złożenie zlecenia zawiera pole status z trzema istotnymi wartościami.

  • matched: zlecenie zostało od razu dopasowane do booka. Inventory rozliczy się w 2–5 sekund. To zwraca FOK/FAK, gdy są skuteczne.
  • delayed: matcher nie mógł rozliczyć synchronicznie i zakolejkował match. Rzadkie; zwykle oznacza congestion. Traktuj to jak matched na potrzeby wzorca wait + verify.
  • posted (nazywane też live): zlecenie siedzi w booku bez filla. Zwracane przez GTC, które nie dopasowały się od razu. Inventory nie jest zmienione; na razie nie trzeba wykonywać kolejnego działania.

Reguła decyzyjna: jeśli status to matched albo delayed, nie składaj żadnego follow-up, które wymaga nowego inventory, dopóki nie zweryfikujesz transferu on-chain.

Polling pattern: sprawdzaj status, zanim zaczniesz świętować

Wzorzec weryfikacji: po udanym matchu polluj balance CTF, aż pokaże nowe tokeny, a dopiero potem idź dalej.

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

Typowy settlement: 2–5 sekund przy dobrych warunkach sieciowych, do 15 s podczas congestion na Polygon. 5-sekundowy wait pokrywa 95% przypadków; w production ustaw timeout na 15 s i generuj alert przy timeout.

Dla botów high-frequency, które nie mogą blokować, alternatywą jest event-subscription: obserwuj event TransferSingle w CTF dla swojego proxy address i uruchamiaj downstream actions po jego otrzymaniu. Przenosi to wait do kolejki zamiast blokować strategy loop.

FOK jako anti-phantom-fill

Wybór FOK zamiast FAK to częściowa obrona przed chaosem phantom-fill. FOK albo realizuje całe zlecenie, albo zwraca cancelled; FAK może zwrócić częściowy filled_size. Gdy po częściowym fillu następuje GTC sell o rozmiarze równym pierwotnemu zleceniu, sell wywala się przez settlement-lag plus mismatch rozmiaru — dwa błędy nakładające się na siebie.

Przy FOK rozmiar jest binarny: albo dopasował się cały rozmiar, albo nic. Logika follow-up zawsze wie, czego się spodziewać.

To nie eliminuje potrzeby wait — nawet idealny match FOK podlega oknu settlement 2–5 sekund. Ale usuwa jedną klasę rozjazdu w bookkeeping.

Idempotent retries z client_order_id

Awaria sieci podczas składania zlecenia tworzy najgorszy możliwy scenariusz: HTTP call bota timed out, ale zlecenie mogło zostać odebrane albo nie. Naiwny retry może spowodować podwójne złożenie; brak retry może zgubić pozycję.

Naprawą jest pole client_order_id przy składaniu zlecenia. Generuj deterministyczny UUID dla każdego zamierzonego zlecenia; jeśli serwer widział już to ID, zwróci status istniejącego zlecenia zamiast tworzyć duplikat.

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

Wzorzec: najpierw generuj ID, retry tylko przy failure transportu, nigdy przy logical rejection. Dedup po stronie serwera działa per API key i jest aktywny przez około 5 minut.

Prawdziwy production incident: jak to naprawiliśmy

Z naszego własnego production diary, maj 2025. Okno 60 minut, w którym trader bot złożył 22 buy orders, wszystkie zostały matched, ale tylko 14 GTC sell zostało przyjętych. Osiem pozycji nie miało wystawionego exit.

Przyczyna główna: bot postował GTC sell w ciągu 800 ms od buy matcha, długo przed tym, jak chain potwierdził transfer ERC-1155. CLOB odrzucał z komunikatem „balance: 0”; bot logował błąd, ale nie robił retry. Osiem pozycji bezgłośnie dotrwało do settlement bez ochrony take-profit. Trzy zamknęły się poniżej oczekiwań; jedna zamknęła się na 0,99 z czystego szczęścia.

Poprawka weszła jako 5-sekundowy blocking wait między każdym buy fill a każdym GTC postem na tym samym tokenie. Zweryfikowane na 30 paper trades plus 30 live trades; od tamtej pory zero błędów balance-zero.

Wniosek: cichy path błędu kosztuje więcej niż głośny. Po tym ustawiliśmy, że wszystkie phantom-fill errors uruchamiają alert w Telegramie, żeby przyszły drift mode był widoczny w ciągu sekund.

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}

Wzorzec przetrwa typowe failure modes: phantom-fill, transient network drop, under-minimum GTC size. Zwraca wystarczająco dużo informacji, by warstwa strategii mogła zdecydować, co retry’ować, co logować, a co zgłaszać alertem.

Najczęściej zadawane pytania

Czym jest phantom fill w Polymarket?
Phantom fill to sytuacja, w której Twój bot uważa, że zlecenie zostało zrealizowane, ale exchange zapisuje je jako jeszcze niezrealizowane (lub częściowo zrealizowane). Dzieje się tak, gdy kod klienta traktuje odpowiedź HTTP 200 jako potwierdzenie, choć odpowiedź oznacza tylko, że zlecenie zostało przyjęte przez matching engine. Nauczyliśmy się tego na własnej skórze - commity 06deaef, 8bb7761 i e68a087 w historii naszego tradera naprawiają dokładnie ten problem.
Jak uniknąć phantom fills?
Trzy zasady: (1) Używaj zleceń FOK dla buy - zlecenie jest albo w pełni zrealizowane, albo całkowicie odrzucone, nigdy niejednoznaczne. (2) Traktuj każdy status inny niż matched jako niezrealizowany - polluj status zlecenia, aż status=matched LUB amount_filled > 0. (3) Używaj client_order_id (clientOrderId w V2) dla idempotence, żeby retry nie powodowały podwójnego filla.
Co oznacza status=delayed?
Zlecenie jest w matching engine, ale nie zostało jeszcze w pełni dopasowane. Może zostać zmatchowane w ciągu sekund albo może pozostać w booku. Zawsze polluj - jeśli status utrzymuje się jako delayed dłużej niż 5–10 sekund, a amount_filled wynosi 0, traktuj je jako niezrealizowane i rozważ anulowanie.
Jak bezpiecznie retry’ować bez podwójnego filla?
Generuj unikalny client_order_id dla każdej logicznej próby transakcji i przekazuj go przy każdym retry. Exchange dedupuje po client_order_id, więc ponownie wysłane zlecenie z tym samym id zostanie odrzucone jako duplikat zamiast ponownie złożone. Implementacje: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Czy mogę ufać odpowiedzi 200 OK z endpointu order?
Nie - 200 OK oznacza tylko „twoje żądanie zostało zaakceptowane przez matching engine”, a nie „twoje zlecenie zostało zrealizowane”. Musisz pollować status zlecenia po orderId po wysłaniu i traktować tylko status=matched (lub amount_filled > 0) jako prawdziwy fill.
Co jeśli mój bot crashnie między wysłaniem zlecenia a zobaczeniem odpowiedzi?
Po restarcie sprawdź open orders i recent fills przez SDK. Porównaj to z lokalnym diary/state - jeśli wysłałeś zlecenie o czasie T, ale nie ma po nim zapisu, zapytaj o orders od T i dopasuj po client_order_id. Jeśli nadal brak, zlecenie nigdy nie dotarło do matching engine i możesz je bezpiecznie wysłać ponownie.