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

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

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

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

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

מהו phantom fill

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 ב-Polymarket?
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 ותוכלו לשלוח אותה שוב בבטחה.