מדריך 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 מתחת למינימום. היא מחזירה מספיק מידע לשכבת האסטרטגיה כדי להחליט מה לנסות שוב, מה לרשום בלוג, ומה להתריע.





