מדריך פולימרקט Bot · פרק 12 מתוך 32

איך לזהות phantom fills בפולימרקט (פקודות שנראות כממולאות אבל לא באמת), לממש retries עם idempotence, להבחין בין status=matched לבין rested-on-book, ולשרוד כשלים זמניים.

מה מכסה הפרק הזה

phantom fill הוא מצב כשל ייחודי לפולימרקט שבו ה-CLOB מאשר match, אבל ה-chain עדיין לא אישרה את העברת ה-ERC-1155. פקודת המשך בתוך בערך 5 שניות תידחה עם שגיאת "balance: 0" מטעה. התרופה היא idempotence והמתנה ל-settlement. זהו playbook הייצור ששילמנו עליו בכסף אמיתי.

  • מהו מילוי רפאים
  • status=matched מול status=delayed מול status=posted
  • תבנית Polling: לבדוק status לפני שחוגגים
  • FOK כהגנה נגד phantom-fill
  • Retries עם idempotent באמצעות client_order_id
  • אירוע ייצור אמיתי: איך תיקנו את שלנו
  • קוד: תבנית detect-then-act לאחר ביצוע פקודה

מהו מילוי רפאים

phantom fill הוא מצב שבו ה-API של ה-CLOB מחזיר על הפקודה שלך status: "matched", אבל העברת ה-ERC-1155 על ה-chain עדיין לא הושלמה. מנגנון ה-matching של ה-CLOB מהיר יותר מייצור בלוקים ב-Polygon (בערך 2 שניות לבלוק). במשך בערך 2-5 שניות אחרי ה-match ב-API, הארנק שלך עדיין לא מחזיק on-chain את הטוקנים שה-matcher אומר ששייכים לך.

הבאג ב-Bot מופיע כשפעולה עוקבת - בדרך כלל sell ב-GTC כדי להציב את ה-take-profit - רצה בתוך החלון הזה. ה-CLOB בודק את יתרת ה-chain, רואה אפס, ודוחה עם not enough balance / allowance: balance: 0, order amount: N. הודעת השגיאה מאשימה את allowance; הסיבה האמיתית היא השהיית settlement.

בפעם הראשונה שזה קורה, קל לחשוב שמדובר בבאג allowance ולבזבז שעה. התרופה פשוטה: לחכות, לאמת, ואז לשלוח.

status=matched מול status=delayed מול status=posted

תגובת ה-order placement כוללת שדה status עם שלושה ערכים חשובים.

  • matched: הפקודה התאימה מיד מול ה-book. ה-inventory יושלם בתוך 2-5 שניות. זה מה ש-FOK/FAK מחזירים כשהם מצליחים.
  • delayed: ה-matcher לא הצליח להשלים synchronously והכניס את ה-match לתור. נדיר; בדרך כלל מצביע על עומס. התייחסו אליו כמו matched לצורך תבנית ההמתנה + האימות.
  • posted (נקרא גם live): הפקודה נשארת resting על ה-book ללא מילוי. מוחזרת על ידי פקודות GTC שלא התאימו מיד. ה-inventory לא מושפע; עדיין אין צורך בפעולת המשך.

כלל ההחלטה: אם ה-status הוא matched או delayed, אל תבצעו שום פקודת המשך שדורשת את ה-inventory החדש עד שתוודאו שה-chain transfer הושלם.

תבנית Polling: לבדוק status לפני שחוגגים

תבנית האימות: אחרי match מוצלח, בצעו polling ליתרת ה-CTF עד שהיא משקפת את הטוקנים החדשים, ואז המשיכו.

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 שניות בזמן עומס ב-Polygon. המתנה של 5 שניות מכסה 95% מהמקרים; ב-production הגדירו timeout של 15 שניות והתריעו על timeout.

עבור bots בתדירות גבוהה שלא יכולים להרשות לעצמם לחסום, חלופה היא event-subscription: לעקוב אחרי ה-TransferSingle של ה-CTF עבור כתובת ה-proxy שלכם ולהפעיל פעולות המשך עם קבלת האירוע. כך ההמתנה עוברת לתור במקום לחסום את לולאת האסטרטגיה.

FOK כהגנה נגד phantom-fill

בחירה ב-FOK במקום FAK היא הגנה חלקית מפני כאוס של phantom-fill. FOK או ממלא את כל הפקודה או מחזיר cancelled; FAK יכול להחזיר filled_size חלקי. כשמילוי חלקי כזה מלווה ב-sell ב-GTC בגודל של הפקודה המקורית, ה-sell נכשל בגלל השהיית settlement יחד עם חוסר התאמה בגודל - שני באגים מצטברים.

עם FOK, הגודל הוא בינארי: או שהגודל המלא תאם או שלא קרה כלום. לוגיקת ה-posting שלאחר מכן תמיד יודעת למה לצפות.

זה לא מבטל את הצורך בהמתנה - אפילו match מושלם ב-FOK עדיין כפוף לחלון ה-settlement של 2-5 שניות. אבל זה מסיר מחלקה אחת של סטייה בחשבונאות.

Retries עם idempotent באמצעות client_order_id

כשיש כשלי רשת בזמן שליחת פקודה, נוצר תרחיש גרוע במיוחד: קריאת ה-HTTP של ה-Bot התזמנה החוצה, אבל ייתכן שהפקודה התקבלה וייתכן שלא. ניסיון חוזר נאיבי עלול ליצור פקודה כפולה; אי-ניסיון חוזר עלול להפיל פוזיציה.

הפתרון הוא השדה 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, ואז מבצעים retry על כשל transport, לעולם לא על דחייה לוגית. ה-dedup בצד השרת הוא לפי API-key, ונמשך בערך 5 דקות.

אירוע ייצור אמיתי: איך תיקנו את שלנו

מתוך יומן ה-production שלנו, מאי 2025. חלון של 60 דקות שבו ה-trader bot ביצע 22 פקודות buy, כולן matched, אבל רק 14 פקודות sell ב-GTC התקבלו. לשמונה פוזיציות לא פורסם exit.

שורש הבעיה: ה-Bot פרסם את ה-sell ב-GTC בתוך 800 מילישניות אחרי ה-buy match, הרבה לפני שה-chain אישרה את העברת ה-ERC-1155. ה-CLOB דחה עם הודעת "balance: 0"; ה-Bot רשם את השגיאה אבל לא ניסה שוב. שמונה פוזיציות התגלגלו בשקט עד ל-settlement ללא הגנת take-profit. שלוש נסגרו מחוץ לכסף; אחת נסגרה ב-0.99 רק בגלל מזל.

התיקון עלה ל-production כהמתנה חוסמת של 5 שניות בין כל buy fill לבין כל GTC post על אותו token. אומת באמצעות 30 paper trades ועוד 30 live trades; מאז אין שגיאות balance-zero.

הלקח: נתיב שגיאה שקט עולה יותר מנתיב רועש. אחרי זה קבענו שכל שגיאות phantom-fill יפעילו התראת Telegram, כך שמצב drift עתידי יהיה גלוי בתוך שניות.

קוד: תבנית detect-then-act לאחר ביצוע פקודה

תבנית production של buy-then-post.

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}

התבנית שורדת את מצבי הכשל הנפוצים: phantom-fill, נפילת רשת זמנית, גודל GTC מתחת למינימום. היא מחזירה מספיק מידע לשכבת האסטרטגיה כדי להחליט מה לנסות שוב, מה לרשום בלוג, ומה להתריע.

שאלות נפוצות

מהו מילוי רפאים בפולימרקט?
phantom fill הוא מצב שבו ה-Bot שלך חושב שפקודה מולאה, אבל ה-exchange מתעד אותה כעדיין לא ממולאת (או ממולאת חלקית). זה קורה כשקוד ה-client מתייחס לתגובת HTTP 200 כאילו היא אישור, בעוד שהתשובה רק אומרת שהפקודה התקבלה ב-matching engine. למדנו את זה בדרך הקשה - ה-commits 06deaef, 8bb7761 ו-e68a087 בהיסטוריית ה-trader שלנו מתקנים בדיוק את זה.
איך אפשר להימנע מ-phantom fills?
שלושה כללים: (1) השתמשו בפקודות FOK לקניות - הפקודה או ממולאת במלואה או נעלמת לגמרי, בלי עמימות. (2) התייחסו לכל status שאינו matched כאל לא-ממולא - בצעו polling ל-status של הפקודה עד ל-status=matched או amount_filled > 0. (3) השתמשו ב-client_order_id (clientOrderId ב-V2) לצורך idempotence, כדי ש-retries לא יגרמו למילוי כפול.
מה המשמעות של status=delayed?
הפקודה נמצאת ב-matching engine אבל עדיין לא הותאמה במלואה. היא עשויה להתאים בתוך שניות או להישאר resting. תמיד בצעו polling - אם ה-status נשאר delayed יותר מ-5-10 שניות ו-amount_filled הוא 0, התייחסו אליה כלא ממולאת ושקלו לבטל.
איך אפשר לבצע retry בבטחה בלי מילוי כפול?
צרו client_order_id ייחודי לכל ניסיון מסחר לוגי והעבירו אותו בכל retry. ה-exchange מבצע dedupe לפי client_order_id, כך שפקודה שנשלחה מחדש עם אותו id תידחה ככפולה במקום להתבצע שוב. מימושים: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
האם אפשר לסמוך על תשובת 200 OK מ-endpoint של פקודה?
לא - 200 OK אומר רק "הבקשה שלך התקבלה על ידי ה-matching engine", לא "הפקודה שלך מולאה". צריך לבצע polling ל-status של הפקודה לפי orderId אחרי השליחה, ולהתייחס רק ל-status=matched (או amount_filled > 0) כאל מילוי אמיתי.
מה אם ה-Bot שלי קורס בין שליחת פקודה לבין ראיית התשובה?
לאחר אתחול מחדש, שאבו את open orders ואת ה-fills האחרונים באמצעות ה-SDK. בצעו reconciliation מול היומן/ה-state המקומי שלכם - אם שלחתם פקודה בזמן T אבל אין רישום, שאבו orders מאז T והתאימו לפי client_order_id. אם עדיין חסר, הפקודה אף פעם לא הגיעה ל-matching engine ותוכלו לשלוח אותה שוב בבטחה.