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.














