Polymarket Bot Tutorial · Розділ 12 з 32

Як виявляти phantom fills у Polymarket (ордера, які виглядають виконаними, але насправді ні), реалізувати idempotent retries, відрізняти status=matched від rested-on-book і переживати transient failures.

Що охоплює цей розділ

Phantom fill - це специфічний для Polymarket failure mode, коли CLOB підтверджує match, але chain ще не підтвердила ERC-1155 transfer. Подальший ордер протягом приблизно 5 секунд буде відхилено з оманливим помилковим повідомленням "balance: 0". Ліки - idempotence і очікування settlement. Цей розділ - production playbook, за який ми заплатили реальними грошима.

  • Що таке phantom fill
  • status=matched vs status=delayed vs status=posted
  • Polling pattern: poll status before celebrating
  • FOK як anti-phantom-fill
  • Idempotent retries з client_order_id
  • Реальний production incident: як ми це виправили
  • Code: detect-then-act post-order pattern

Що таке phantom fill

Phantom fill - це коли CLOB API відповідає на ваш ордер status: "matched", але on-chain ERC-1155 transfer ще не settle-нувся. CLOB matcher працює швидше, ніж виробництво блоків Polygon (~2 с на блок). Протягом приблизно 2-5 секунд після API match ваш wallet ще не тримає on-chain ті токени, які, за словами matcher, ви вже маєте.

Помилка в bot з’являється тоді, коли follow-up action - зазвичай GTC sell для виставлення take-profit - виконується в цьому вікні. CLOB перевіряє chain balance, бачить нуль і відхиляє з not enough balance / allowance: balance: 0, order amount: N. Повідомлення про помилку звинувачує allowance; причина - settlement lag.

Першого разу, коли це трапляється, ви думаєте, що це allowance bug, і марнуєте годину. Ліки прості: зачекати, перевірити, потім post.

status=matched vs status=delayed vs status=posted

Відповідь на розміщення ордера містить field status із трьома важливими значеннями.

  • matched: ордер одразу matched проти book. Inventory settle-иться за 2-5 секунд. Саме це повертають FOK/FAK у разі успіху.
  • delayed: matcher не зміг settle-нути синхронно і поставив match у чергу. Рідко; зазвичай означає congestion. Для цілей wait + verify pattern поводьтеся так само, як із matched.
  • posted (також називається live): ордер resting on the book без виконання. Повертається GTC ордерами, які не matched одразу. Inventory не змінюється; поки що follow-up action не потрібна.

Правило прийняття рішення: якщо status - matched або delayed, не розміщуйте жодних follow-up, які потребують новий inventory, доки не перевірите chain transfer.

Polling pattern: poll status before celebrating

Патерн перевірки: після успішного match poll-те CTF balance, доки він не відобразить нові токени, а потім продовжуйте.

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: 2-5 секунд за хороших мережевих умов, до 15 с під час congestion у Polygon. Очікування 5 секунд покриває 95% випадків; для production встановіть timeout 15 с і надсилайте alert на timeout.

Для high-frequency bots, які не можуть дозволити собі блокування, є альтернатива - event-subscription: відстежуйте TransferSingle event у CTF для вашої proxy address і запускайте downstream actions після отримання події. Це переносить wait у queue замість блокування strategy loop.

FOK як anti-phantom-fill

Вибір FOK замість FAK - це частковий захист від phantom-fill chaos. FOK або виконує весь ордер, або повертає cancelled; FAK може повернути filled_size, який є частковим. Коли за partial fill іде GTC sell, розрахований на початковий ордер, sell падає через settlement-lag плюс size mismatch - дві помилки, що накладаються одна на одну.

З FOK size є бінарним: або matched весь повний size, або не відбулося нічого. Follow-up posting logic завжди знає, чого очікувати.

Це не усуває потребу у wait - навіть ідеальний FOK match підпорядковується 2-5-секундному window settlement. Але це прибирає один клас розбіжностей у bookkeeping.

Idempotent retries з client_order_id

Мережеві збої під час розміщення ордера створюють найгірший сценарій: HTTP call у bot таймаутнувся, але ордер міг бути або не бути отриманим. Наївний retry може створити дубль; відсутність retry може зірвати позицію.

Рішення - field client_order_id у розміщенні ордера. Генеруйте deterministic UUID для кожного запланованого ордера; якщо server уже бачив цей ID, він повертає status існуючого ордера замість створення дубліката.

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

Патерн: спочатку generate ID, потім retry при transport failure, ніколи не при logical rejection. Server-side dedup діє per-API-key і триває приблизно 5 хвилин.

Реальний production incident: як ми це виправили

З нашого власного production diary, травень 2025. Вікно на 60 хвилин, протягом якого trader bot розмістив 22 buy orders, усі matched, але лише 14 GTC sells були прийняті. Восьмеро позицій залишилися без posted exit.

Root cause: bot відправляв GTC sell через 800 мс після buy match, задовго до того, як chain підтвердила ERC-1155 transfer. CLOB відхиляв із повідомленням "balance: 0"; bot логував помилку, але не повторював спробу. Вісім позицій тихо дійшли до resolution без take-profit protection. Три закрилися out of the money; одна закрилася на 0.99 просто пощастило.

Виправлення вийшло як 5-секундне blocking wait між будь-яким buy fill і будь-яким GTC post на тому самому token. Перевірено через 30 paper trades плюс 30 live trades; відтоді - жодної balance-zero помилки.

Урок: тихий error path дорожчий за гучний. Після цього ми зробили так, щоб усі phantom-fill errors запускали Telegram alert, тож майбутній drift mode буде видно за секунди.

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}

Патерн витримує поширені failure modes: phantom-fill, transient network drop, under-minimum GTC size. Він повертає достатньо інформації, щоб strategy layer вирішив, що retry-нути, що залогувати, а що підняти як alert.

Часті запитання

Що таке phantom fill у Polymarket?
Phantom fill - це коли ваш bot вважає, що ордер виконано, але exchange фіксує його як такий, що ще не виконаний (або partially filled). Це трапляється, коли client code сприймає HTTP 200 як підтвердження, хоча відповідь лише означає, що ордер був прийнятий у matching engine. Ми дізналися це на власному болючому досвіді - commits 06deaef, 8bb7761 і e68a087 в історії нашого trader виправляють саме це.
Як уникнути phantom fills?
Три правила: (1) Використовуйте FOK orders для buy - ордер або повністю filled, або повністю зникає, без неоднозначності. (2) Сприймайте будь-який status, що не matched, як unfilled - poll-те order status, доки status=matched АБО amount_filled > 0. (3) Використовуйте client_order_id (clientOrderId у V2) для idempotence, щоб retries не створювали double-fill.
Що означає status=delayed?
Ордер є в matching engine, але ще не fully matched. Він може matched за кілька секунд або може resting. Завжди poll-те - якщо status залишається delayed понад 5-10 секунд і amount_filled дорівнює 0, вважайте його unfilled і подумайте про cancel.
Як безпечно retry-ити без double-filling?
Генеруйте унікальний client_order_id для кожної логічної спроби угоди й передавайте його в кожному retry. Exchange dedupe-ить за client_order_id, тож повторний ордер із тим самим id буде відхилено як duplicate, а не розміщено ще раз. Реалізації: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Чи можна довіряти відповіді 200 OK від order endpoint?
Ні - 200 OK означає лише "ваш запит прийнято matching engine", а не "ваш ордер виконано". Потрібно poll-ити order status за orderId після submission і вважати реальним fill лише status=matched (або amount_filled > 0).
Що робити, якщо bot падає між відправкою ордера і отриманням відповіді?
Після перезапуску запитайте open orders і recent fills через SDK. Зіставте це з локальним diary/state - якщо ви надіслали ордер у час T, але запису немає, запитайте orders після T і зіставте за client_order_id. Якщо й досі відсутній, ордер не дійшов до matching engine, і його можна безпечно надіслати ще раз.