آموزش Polymarket Bot · فصل ۱۲ از ۳۲

چطور phantom fillهای Polymarket را تشخیص دهید (سفارش‌هایی که ظاهراً filled شده‌اند اما نشده‌اند)، retryهای idempotent را پیاده‌سازی کنید، status=matched را از rested-on-book تشخیص دهید، و از failureهای موقت جان سالم به در ببرید.

این فصل چه چیزهایی را پوشش می‌دهد

phantom fill یک failure mode مخصوص Polymarket است که در آن CLOB یک match را تأیید می‌کند، اما chain هنوز transfer مربوط به ERC-1155 را confirm نکرده است. یک order بعدی در حدود ~۵ ثانیه بعد با یک error گمراه‌کننده‌ی "balance: 0" رد می‌شود. درمان، idempotence و یک settlement wait است. این فصل، playbook عملیاتی‌ای است که برایش پول واقعی پرداخت کردیم.

این فصل ۱۲ از سری ۳۲ بخشی ما درباره ساختن یک Polymarket trading bot است. موضوع را در بخش‌های زیر به‌صورت عمیق پوشش می‌دهیم. محتوای اصلی هر بخش در حال نوشته‌شدن و انتشار مرحله‌به‌مرحله است؛ پاسخ‌های FAQ و منابع از قبل کامل شده‌اند و تجربه‌ی production حاصل از اجرای trader خودمان را منعکس می‌کنند.

  • phantom fill چیست
  • status=matched در برابر status=delayed در برابر status=posted
  • Polling pattern: قبل از خوشحالی، status را poll کنید
  • FOK به‌عنوان anti-phantom-fill
  • Idempotent retries با client_order_id
  • یک incident واقعی در production: چطور مشکل خودمان را حل کردیم
  • Code: detect-then-act post-order pattern

phantom fill چیست

phantom fill زمانی است که CLOB API به order شما با status: "matched" پاسخ می‌دهد، اما transfer on-chain مربوط به ERC-1155 هنوز settle نشده است. matcher در CLOB از تولید block در Polygon سریع‌تر است (~۲ ثانیه برای هر block). حدود ۲ تا ۵ ثانیه پس از API match، wallet شما هنوز on-chain توکن‌هایی را که matcher می‌گوید مالک آن‌ها هستید، در اختیار ندارد.

bug ربات وقتی ظاهر می‌شود که یک اقدام بعدی - معمولاً یک GTC sell برای ثبت take-profit - در همان بازه اجرا شود. CLOB موجودی chain را check می‌کند، صفر می‌بیند، و با not enough balance / allowance: balance: 0, order amount: N رد می‌کند. پیام error تقصیر را گردن allowance می‌اندازد؛ علت، settlement lag است.

اولین‌بار که این اتفاق می‌افتد، فکر می‌کنید مشکل allowance است و یک ساعت وقت تلف می‌کنید. درمان ساده است: صبر کنید، verify کنید، سپس post کنید.

status=matched در برابر status=delayed در برابر status=posted

پاسخ placement order شامل یک status field است که سه مقدار مهم دارد.

  • matched: order فوراً با book match شده است. inventory ظرف ۲ تا ۵ ثانیه settle می‌شود. این همان چیزی است که FOK/FAK در صورت موفقیت برمی‌گردانند.
  • delayed: matcher نتوانسته settlement را به‌صورت synchronous انجام دهد و match را queue کرده است. نادر است؛ معمولاً نشانه congestion است. برای patternِ wait + verify مثل matched با آن رفتار کنید.
  • posted (که live هم نامیده می‌شود): order روی book rest شده و filled نشده است. این وضعیت توسط GTC orderهایی برمی‌گردد که فوراً match نشده‌اند. inventory تحت تأثیر قرار نمی‌گیرد؛ هنوز اقدام بعدی لازم نیست.

قانون تصمیم‌گیری: اگر status برابر matched یا delayed است، تا زمانی که chain transfer را verify نکرده‌اید، هیچ follow-upای که به inventory جدید نیاز دارد را place نکنید.

Polling pattern: قبل از خوشحالی status را poll کنید

pattern verify: بعد از یک match موفق، CTF balance را poll کنید تا توکن‌های جدید را نشان دهد، سپس ادامه دهید.

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 معمول: در شرایط خوب شبکه ۲ تا ۵ ثانیه، و هنگام congestion در Polygon تا ۱۵ ثانیه. یک wait پنج‌ثانیه‌ای ۹۵٪ موارد را پوشش می‌دهد؛ برای production timeout را ۱۵ ثانیه بگذارید و روی timeout alert بدهید.

برای botهای high-frequency که نمی‌توانند block شوند، یک alternative این است که از event-subscription استفاده کنید: event TransferSingle در CTF را برای proxy address خود watch کنید و به محض دریافت، actionهای downstream را trigger کنید. این کار wait را به یک queue منتقل می‌کند به‌جای اینکه loop استراتژی را block کند.

FOK به‌عنوان anti-phantom-fill

انتخاب FOK به‌جای FAK یک دفاع نسبی در برابر chaos ناشی از phantom fill است. FOK یا کل order را filled می‌کند یا cancelled برمی‌گرداند؛ FAK می‌تواند filled_size جزئی برگرداند. وقتی یک partial fill با یک GTC sell به اندازه‌ی order اصلی دنبال شود، sell به‌خاطر settlement lag به‌علاوه mismatch اندازه fail می‌شود - دو bug که هم‌زمان روی هم اثر می‌گذارند.

با FOK، اندازه binary است: یا کل size match شده یا هیچ‌چیز. منطق posting بعدی همیشه می‌داند باید چه انتظاری داشته باشد.

این کار نیاز به wait را از بین نمی‌برد - حتی یک FOK match کامل هم در معرض window settlement ۲ تا ۵ ثانیه‌ای است. اما یک class از divergence در bookkeeping را حذف می‌کند.

Idempotent retries با client_order_id

failureهای شبکه هنگام placement order بدترین سناریو را ایجاد می‌کنند: call HTTP ربات timeout شده، اما order ممکن است received شده باشد یا نباشد. retry کردنِ کورکورانه می‌تواند order را دو بار place کند؛ retry نکردن می‌تواند یک position را از دست بدهد.

راه‌حل، field client_order_id در placement order است. برای هر order موردنظر یک UUID deterministic تولید کنید؛ اگر server قبلاً آن ID را دیده باشد، status order موجود را برمی‌گرداند به‌جای اینکه duplicate بسازد.

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 retry کنید، و هرگز روی logical rejection retry نکنید. dedup سمت server برای هر API key اعمال می‌شود و حدود ۵ دقیقه دوام دارد.

یک incident واقعی در production: چطور مشکل خودمان را حل کردیم

از دفترچه production خودمان، مه 2025. یک بازه ۶۰ دقیقه‌ای که bot معامله‌گر ۲۲ خرید انجام داد، همه matched شدند، اما فقط ۱۴ فروش GTC پذیرفته شد. هشت position هیچ exitای نداشتند.

علت ریشه‌ای: bot فروش GTC را فقط ۸۰۰ میلی‌ثانیه بعد از buy match ارسال می‌کرد، خیلی قبل از اینکه chain انتقال ERC-1155 را confirm کند. CLOB با پیام "balance: 0" رد می‌کرد؛ bot error را log می‌کرد اما retry نمی‌کرد. هشت position بدون حفاظت take-profit تا زمان resolution پیش رفتند. سه مورد با ضرر بسته شدند؛ یکی هم از شانس با 0.99 بسته شد.

راه‌حل به‌صورت یک wait بلاک‌کننده‌ی ۵ ثانیه‌ای بین هر buy fill و هر GTC post روی همان token منتشر شد. با ۳۰ paper trade به‌علاوه ۳۰ live trade verify شد؛ از آن زمان هیچ balance-zero errorی نداشته‌ایم.

درس: یک مسیر error خاموش از یک مسیر error پرصدا گران‌تر است. بعد از این، همه phantom-fill errorها را مجبور کردیم یک Telegram alert trigger کنند تا هر 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 در برابر failure modeهای رایج دوام می‌آورد: phantom-fill، قطع موقت شبکه، و اندازه‌ی GTC کمتر از حداقل. اطلاعات کافی برمی‌گرداند تا لایه استراتژی بتواند تصمیم بگیرد چه چیزی retry شود، چه چیزی log شود، و چه چیزی alert بگیرد.

سوالات متداول

phantom fill در Polymarket چیست؟
phantom fill زمانی است که bot شما فکر می‌کند یک order filled شده، اما exchange آن را هنوز filled نشده (یا partially filled) ثبت کرده است. این اتفاق وقتی می‌افتد که کد کلاینت، پاسخ HTTP 200 را confirmation در نظر بگیرد، در حالی که پاسخ فقط یعنی order وارد matching engine شده است. ما این را به سختی یاد گرفتیم - commitهای 06deaef، 8bb7761 و e68a087 در تاریخچه trader ما دقیقاً این مشکل را رفع می‌کنند.
چطور از phantom fill جلوگیری کنم؟
سه قانون: (1) برای buyها از FOK استفاده کنید - order یا کامل filled می‌شود یا کامل از بین می‌رود، و هرگز مبهم نیست. (2) هر status غیر از matched را معادل not filled در نظر بگیرید - status order را poll کنید تا status=matched OR amount_filled > 0 شود. (3) برای idempotence از client_order_id (clientOrderId در V2) استفاده کنید تا retryها باعث double-fill نشوند.
status=delayed یعنی چه؟
یعنی order داخل matching engine است اما هنوز fully matched نشده. ممکن است ظرف چند ثانیه match شود یا ممکن است rest بماند. همیشه poll کنید - اگر status بیش از ۵ تا ۱۰ ثانیه delayed بماند و amount_filled برابر ۰ باشد، آن را unfilled در نظر بگیرید و cancel کردن را بررسی کنید.
چطور امن retry کنم بدون double-fill؟
برای هر تلاش منطقی trade یک client_order_id یکتا تولید کنید و در هر retry همان را پاس دهید. exchange بر اساس client_order_id dedupe می‌کند، بنابراین orderی که با همان id retry شده باشد به‌عنوان duplicate رد می‌شود، نه اینکه دوباره place شود. پیاده‌سازی‌ها: Python OrderArgs.client_order_id، Node CreateOrderOptions.clientOrderId.
آیا می‌توانم به پاسخ 200 OK از order endpoint اعتماد کنم؟
خیر - 200 OK فقط یعنی "درخواست شما توسط matching engine پذیرفته شد"، نه اینکه "order شما filled شد". باید بعد از submission، status order را با orderId poll کنید و فقط status=matched (یا amount_filled > 0) را به‌عنوان fill واقعی در نظر بگیرید.
اگر bot من بین ارسال order و دیدن پاسخ crash کند چه؟
بعد از restart، open orders و recent fills را از طریق SDK query کنید. آن را با دفترچه/وضعیت محلی خود reconcile کنید - اگر در زمان T یک order ارسال کرده‌اید اما هیچ رکوردی وجود ندارد، orderهای بعد از T را query کنید و با client_order_id match کنید. اگر هنوز مفقود بود، order هرگز به matching engine نرسیده و می‌توانید با خیال راحت دوباره ارسال کنید.