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. Следующий order в течение примерно 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 отвечает на ваш order со status: "matched", но on-chain ERC-1155 transfer еще не settled. CLOB matcher работает быстрее, чем производство блоков Polygon (~2s на блок). В течение примерно 2-5 секунд после API match ваш wallet on-chain еще не хранит tokens, которые, как утверждает matcher, принадлежат вам.

Баг в bot проявляется, когда следующий шаг - обычно GTC sell для выставления take-profit - запускается в этом окне. CLOB проверяет chain balance, видит ноль и отклоняет с not enough balance / allowance: balance: 0, order amount: N. Сообщение об ошибке обвиняет allowance; реальная причина - settlement lag.

В первый раз, когда это случается, вы думаете, что проблема в allowance, и теряете час. Решение простое: подождать, проверить, затем выставить.

status=matched vs status=delayed vs status=posted

Ответ на размещение order включает поле status с тремя важными значениями.

  • matched: order немедленно matched against the book. Inventory settles через 2-5 seconds. Именно это возвращают FOK/FAK при успехе.
  • delayed: matcher не смог синхронно выполнить settlement и поставил match в очередь. Редко; обычно указывает на congestion. Для wait + verify pattern относитесь к нему так же, как к matched.
  • posted (также называется live): order resting on the book без исполнения. Возвращается GTC orders, которые не matched немедленно. Inventory не затронут; последующие действия пока не нужны.

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

Polling pattern: poll status before celebrating

Pattern проверки: после успешного match опрашивайте CTF balance, пока он не отразит новые tokens, а затем продолжайте.

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 секунд при нормальных network conditions, до 15s во время congestion в Polygon. Пятисекундное ожидание покрывает 95% случаев; для production задайте timeout 15s и поднимайте alert при timeout.

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

FOK как anti-phantom-fill

Выбор FOK вместо FAK - это частичная защита от хаоса phantom-fill. FOK либо исполняет весь order, либо возвращает cancelled; FAK может вернуть filled_size частичного объема. Когда за partial fill следует GTC sell, рассчитанный на исходный order, sell терпит неудачу из-за settlement-lag плюс mismatch размера - две ошибки, усиливающие друг друга.

С FOK размер бинарный: либо полностью matched нужный size, либо не произошло ничего. Логика follow-up posting всегда знает, чего ожидать.

Это не устраняет необходимость ожидания - даже идеальный FOK match зависит от 2-5 секундного окна settlement. Но это убирает один класс расхождений в bookkeeping.

Idempotent retries с client_order_id

Сетевые сбои во время размещения order создают worst-case scenario: HTTP-вызов bot истек по timeout, но order мог быть, а мог и не быть получен. Наивный retry может создать дубль; отсутствие retry может потерять позицию.

Решение - поле client_order_id при размещении order. Генерируйте deterministic UUID для каждого предполагаемого order; если сервер уже видел этот ID, он вернет статус существующего order вместо создания дубликата.

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

Pattern такой: сначала генерируйте ID, повторяйте при transport failure, но никогда при logical rejection. Server-side dedup действует per-API-key и хранится примерно 5 minutes.

Реальный production incident: как мы это исправили

Из нашего собственного production diary, май 2025. 60-минутное окно, в течение которого trader bot выставил 22 buy orders, все matched, но только 14 GTC sells были приняты. У восьми позиций не было выставлено exit.

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

Исправление вышло в виде 5-second blocking wait между любым buy fill и любым GTC post по тому же token. Проверено на 30 paper trades плюс 30 live trades; с тех пор ошибок balance-zero не было.

Урок: silent error path дороже, чем loud one. После этого мы сделали так, чтобы все 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}

Pattern переживает common failure modes: phantom-fill, transient network drop, under-minimum GTC size. Возвращает достаточно информации, чтобы strategy layer решила, что retry, что log, а что alert.

Часто задаваемые вопросы

Что такое phantom fill на Polymarket?
Phantom fill - это когда ваш bot считает, что order исполнен, но exchange записывает его как еще не исполненный (или partially filled). Это происходит, когда client code воспринимает HTTP 200 response как подтверждение, хотя response лишь означает, что order был принят matching engine. Мы выучили это на собственном горьком опыте - commits 06deaef, 8bb7761 и e68a087 в истории нашего trader исправляют именно это.
Как избежать phantom fills?
Три правила: (1) Используйте FOK orders для buys - order либо полностью filled, либо полностью исчезает, без двусмысленности. (2) Считайте любой status, отличный от matched, как неисполненный - опрашивайте status order, пока status=matched ИЛИ amount_filled > 0. (3) Используйте client_order_id (clientOrderId в V2) для idempotence, чтобы retries не создавали двойное исполнение.
Что означает status=delayed?
Order находится в matching engine, но еще не был fully matched. Он может matched через несколько секунд или может rest. Всегда делайте poll - если status остается delayed более 5-10 seconds и amount_filled равен 0, считайте его неисполненным и подумайте об отмене.
Как безопасно повторять попытку без двойного исполнения?
Генерируйте уникальный client_order_id для каждой логической попытки сделки и передавайте его при каждом retry. Exchange dedupes по client_order_id, поэтому повторный order с тем же id будет отклонен как duplicate, а не размещен снова. Реализации: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Можно ли доверять ответу 200 OK от order endpoint?
Нет - 200 OK означает лишь "ваш запрос был принят matching engine", а не "ваш order исполнен". После отправки нужно опрашивать order status по orderId и считать реальным fill только status=matched (или amount_filled > 0).
Что делать, если bot упал между отправкой order и получением response?
После перезапуска запросите open orders и recent fills через SDK. Сопоставьте их с локальным diary/state - если вы отправили order в момент T, но записи нет, запросите orders начиная с T и сопоставьте по client_order_id. Если его все равно нет, order не дошел до matching engine, и его можно безопасно отправить снова.