آموزش ربات پلی‌مارکت ·

چطور پرشدن‌های فانتوم پلی‌مارکت را تشخیص دهید (سفارش‌هایی که ظاهراً پر شده‌اند اما نشده‌اند)، تلاش‌های مجدد idempotent را پیاده‌سازی کنید، status=matched را از وضعیت باقی‌مانده روی دفتر (rested-on-book) تشخیص دهید، و از شکست‌های گذرا جان سالم به در ببرید.

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

پرشدن فانتوم یک حالت شکست مخصوص پلی‌مارکت است که در آن CLOB یک تطبیق را تأیید می‌کند، اما زنجیره (chain) هنوز انتقال مربوط به ERC-1155 را تأیید نکرده است. یک سفارش بعدی در حدود ~۵ ثانیه بعد با یک خطای گمراه‌کننده‌ی "balance: 0" رد می‌شود. درمان، idempotence و یک انتظار برای تسویه (settlement) است. این فصل، راهنمای عملیاتی‌ای است که برایش پول واقعی پرداخت کردیم.

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

  • پرشدن فانتوم چیست
  • status=matched در برابر status=delayed در برابر status=posted
  • الگوی Polling: قبل از خوشحالی، status را poll کنید
  • FOK به‌عنوان سازوکار ضدِ پرشدن فانتوم
  • تلاش‌های مجدد idempotent با client_order_id
  • یک رخداد واقعی در production: چطور مشکل خودمان را حل کردیم
  • کد: الگوی detect-then-act پس از ثبت سفارش

پرشدن فانتوم چیست

پرشدن فانتوم زمانی است که CLOB API به سفارش شما با status: "matched" پاسخ می‌دهد، اما انتقال on-chain مربوط به ERC-1155 هنوز تسویه نشده است. تطبیق‌دهنده در CLOB از تولید بلاک در Polygon سریع‌تر است (~۲ ثانیه برای هر بلاک). حدود ۲ تا ۵ ثانیه پس از تطبیق در API، کیف پول شما هنوز on-chain توکن‌هایی را که تطبیق‌دهنده می‌گوید مالک آن‌ها هستید، در اختیار ندارد.

باگِ ربات وقتی ظاهر می‌شود که یک اقدام بعدی - معمولاً یک فروش GTC برای ثبت حد سود - در همان بازه اجرا شود. CLOB موجودیِ زنجیره را بررسی می‌کند، صفر می‌بیند، و با not enough balance / allowance: balance: 0, order amount: N رد می‌کند. پیام خطا تقصیر را گردن allowance می‌اندازد؛ علت، تأخیر تسویه (settlement lag) است.

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

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

پاسخِ ثبت سفارش شامل یک فیلد status است که سه مقدار مهم دارد.

  • matched: سفارش فوراً با دفتر تطبیق داده شده است. موجودی ظرف ۲ تا ۵ ثانیه تسویه می‌شود. این همان چیزی است که FOK/FAK در صورت موفقیت برمی‌گردانند.
  • delayed: تطبیق‌دهنده نتوانسته تسویه را به‌صورت همگام (synchronous) انجام دهد و تطبیق را در صف قرار داده است. نادر است؛ معمولاً نشانهٔ ازدحام است. برای الگوی «انتظار + تأیید» مثل matched با آن رفتار کنید.
  • posted (که live هم نامیده می‌شود): سفارش روی دفتر باقی مانده و پر نشده است. این وضعیت توسط سفارش‌های GTC برمی‌گردد که فوراً تطبیق نیافته‌اند. موجودی تحت تأثیر قرار نمی‌گیرد؛ هنوز اقدام بعدی لازم نیست.

قانون تصمیم‌گیری: اگر status برابر matched یا delayed است، تا زمانی که انتقال روی زنجیره را تأیید نکرده‌اید، هیچ اقدام بعدی‌ای که به موجودی جدید نیاز دارد را ثبت نکنید.

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

الگوی تأیید: بعد از یک تطبیق موفق، موجودی CTF را 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

تسویهٔ معمول: در شرایط خوب شبکه ۲ تا ۵ ثانیه، و هنگام ازدحام در Polygon تا ۱۵ ثانیه. یک انتظار پنج‌ثانیه‌ای ۹۵٪ موارد را پوشش می‌دهد؛ برای production مقدار timeout را ۱۵ ثانیه بگذارید و هنگام timeout هشدار بدهید.

برای ربات‌های پربسامد که نمی‌توانند بلاک شوند، یک جایگزین این است که از اشتراک رویداد استفاده کنید: رویداد TransferSingle در CTF را برای آدرس پراکسی خود رصد کنید و به محض دریافت، اقدام‌های پایین‌دستی را فعال کنید. این کار انتظار را به یک صف منتقل می‌کند به‌جای اینکه حلقهٔ استراتژی را بلاک کند.

FOK به‌عنوان سازوکار ضدِ پرشدن فانتوم

انتخاب FOK به‌جای FAK یک دفاع نسبی در برابر آشوبِ ناشی از پرشدن فانتوم است. FOK یا کل سفارش را پر می‌کند یا cancelled برمی‌گرداند؛ FAK می‌تواند filled_size جزئی برگرداند. وقتی یک پرشدن جزئی با یک فروش GTC به اندازهٔ سفارش اصلی دنبال شود، فروش به‌خاطر تأخیر تسویه به‌علاوهٔ ناهم‌خوانی اندازه شکست می‌خورد - دو باگ که هم‌زمان روی هم اثر می‌گذارند.

با FOK، اندازه دودویی است: یا کل اندازه تطبیق شده یا هیچ‌چیز. منطق ثبتِ بعدی همیشه می‌داند باید چه انتظاری داشته باشد.

این کار نیاز به انتظار را از بین نمی‌برد - حتی یک تطبیق کامل FOK هم در معرض پنجرهٔ تسویهٔ ۲ تا ۵ ثانیه‌ای است. اما یک دسته از واگرایی در دفترداری را حذف می‌کند.

تلاش‌های مجدد idempotent با client_order_id

شکست‌های شبکه هنگام ثبت سفارش بدترین سناریو را ایجاد می‌کنند: فراخوانی HTTP ربات تایم‌اوت شده، اما ممکن است سفارش دریافت شده باشد یا نباشد. تلاش مجددِ کورکورانه می‌تواند سفارش را دو بار ثبت کند؛ تلاش‌نکردن مجدد می‌تواند یک موقعیت را از دست بدهد.

راه‌حل، فیلد client_order_id در ثبت سفارش است. برای هر سفارش موردنظر یک UUID قطعی تولید کنید؛ اگر سرور قبلاً آن 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")

الگو: اول ID را تولید کنید، روی شکست انتقال تلاش مجدد کنید، و هرگز روی رد منطقی تلاش مجدد نکنید. حذف موارد تکراری سمت سرور برای هر API key اعمال می‌شود و حدود ۵ دقیقه دوام دارد.

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

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

علت ریشه‌ای: ربات فروش GTC را فقط ۸۰۰ میلی‌ثانیه بعد از تطبیق خرید ارسال می‌کرد، خیلی قبل از اینکه زنجیره انتقال ERC-1155 را تأیید کند. CLOB با پیام "balance: 0" رد می‌کرد؛ ربات خطا را لاگ می‌کرد اما تلاش مجدد نمی‌کرد. هشت موقعیت بدون حفاظت حد سود تا زمان حل‌وفصل پیش رفتند. سه مورد با ضرر بسته شدند؛ یکی هم از شانس با 0.99 بسته شد.

راه‌حل به‌صورت یک انتظارِ بلاک‌کننده‌ی ۵ ثانیه‌ای بین هر پرشدن خرید و هر ثبت GTC روی همان توکن منتشر شد. با ۳۰ معاملهٔ کاغذی به‌علاوهٔ ۳۰ معاملهٔ زنده تأیید شد؛ از آن زمان هیچ خطای balance-zero نداشته‌ایم.

درس: یک مسیر خطای خاموش از یک مسیر خطای پرصدا گران‌تر است. بعد از این، همهٔ خطاهای پرشدن فانتوم را مجبور کردیم یک هشدار Telegram فعال کنند تا هر حالت انحراف آینده ظرف چند ثانیه قابل مشاهده باشد.

کد: الگوی detect-then-act پس از ثبت سفارش

الگوی «خرید سپس ثبت» در production.

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}

این الگو در برابر حالت‌های شکستِ رایج دوام می‌آورد: پرشدن فانتوم، قطع موقت شبکه، و اندازهٔ GTC کمتر از حداقل. اطلاعات کافی برمی‌گرداند تا لایهٔ استراتژی بتواند تصمیم بگیرد چه چیزی تلاش مجدد شود، چه چیزی لاگ شود، و چه چیزی هشدار بگیرد.

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

پرشدن فانتوم در پلی‌مارکت چیست؟
پرشدن فانتوم زمانی است که ربات شما فکر می‌کند یک سفارش پر شده، اما صرافی آن را هنوز پر نشده (یا به‌صورت جزئی پر شده) ثبت کرده است. این اتفاق وقتی می‌افتد که کد کلاینت، پاسخ HTTP 200 را تأییدیه در نظر بگیرد، در حالی که پاسخ فقط یعنی سفارش وارد موتور تطبیق شده است. ما این را به سختی یاد گرفتیم - کامیت‌های 06deaef، 8bb7761 و e68a087 در تاریخچهٔ معامله‌گر ما دقیقاً این مشکل را رفع می‌کنند.
چطور از پرشدن فانتوم جلوگیری کنم؟
سه قانون: (1) برای خریدها از FOK استفاده کنید - سفارش یا کامل پر می‌شود یا کامل از بین می‌رود، و هرگز مبهم نیست. (2) هر status غیر از matched را معادل پر نشده در نظر بگیرید - status سفارش را poll کنید تا status=matched OR amount_filled > 0 شود. (3) برای idempotence از client_order_id (clientOrderId در V2) استفاده کنید تا تلاش‌های مجدد باعث پرشدن دوگانه نشوند.
status=delayed یعنی چه؟
یعنی سفارش داخل موتور تطبیق است اما هنوز به‌طور کامل تطبیق نیافته. ممکن است ظرف چند ثانیه تطبیق شود یا ممکن است باقی بماند. همیشه poll کنید - اگر status بیش از ۵ تا ۱۰ ثانیه delayed بماند و amount_filled برابر ۰ باشد، آن را پر نشده در نظر بگیرید و لغو کردن را بررسی کنید.
چطور امن تلاش مجدد کنم بدون پرشدن دوگانه؟
برای هر تلاش منطقی معامله یک client_order_id یکتا تولید کنید و در هر تلاش مجدد همان را پاس دهید. صرافی بر اساس client_order_id حذف تکراری انجام می‌دهد، بنابراین سفارشی که با همان id تلاش مجدد شده باشد به‌عنوان مورد تکراری رد می‌شود، نه اینکه دوباره ثبت شود. پیاده‌سازی‌ها: Python OrderArgs.client_order_id، Node CreateOrderOptions.clientOrderId.
آیا می‌توانم به پاسخ 200 OK از endpoint سفارش اعتماد کنم؟
خیر - 200 OK فقط یعنی «درخواست شما توسط موتور تطبیق پذیرفته شد»، نه اینکه «سفارش شما پر شد». باید بعد از ارسال، status سفارش را با orderId poll کنید و فقط status=matched (یا amount_filled > 0) را به‌عنوان پرشدن واقعی در نظر بگیرید.
اگر ربات من بین ارسال سفارش و دیدن پاسخ کرش کند چه؟
بعد از راه‌اندازی مجدد، سفارش‌های باز و پرشدن‌های اخیر را از طریق SDK پرس‌وجو کنید. آن را با دفترچه/وضعیت محلی خود تطبیق دهید - اگر در زمان T یک سفارش ارسال کرده‌اید اما هیچ رکوردی وجود ندارد، سفارش‌های بعد از T را پرس‌وجو کنید و با client_order_id تطبیق دهید. اگر هنوز مفقود بود، سفارش هرگز به موتور تطبیق نرسیده و می‌توانید با خیال راحت دوباره ارسال کنید.