Polymarket Bot Tutorial · บทที่ 12 จาก 32
วิธีตรวจจับ Polymarket phantom fills (คำสั่งที่ดูเหมือนเติมเต็มแต่จริง ๆ ไม่ใช่), implement idempotent retries, แยกความแตกต่างระหว่าง status=matched กับ rested-on-book, และเอาตัวรอดจาก transient failures.
บทนี้ครอบคลุมอะไรบ้าง
phantom fill คือ failure mode เฉพาะของ Polymarket ที่ CLOB ยืนยันการ match แล้ว แต่ chain ยังไม่ confirm การโอน ERC-1155 การสั่งงานต่อเนื่องภายในประมาณ 5 วินาทีจะถูกปฏิเสธด้วย error ที่ทำให้เข้าใจผิดว่าเป็น "balance: 0" วิธีแก้คือ idempotence และการรอ settlement บทนี้คือ playbook ระดับ production ที่เราจ่ายด้วยเงินจริงเพื่อเรียนรู้มา
- phantom fill คืออะไร
- status=matched vs status=delayed vs status=posted
- Polling pattern: poll status ก่อนฉลอง
- FOK ในฐานะ anti-phantom-fill
- Idempotent retries ด้วย client_order_id
- เหตุการณ์จริงใน production: เราแก้ปัญหาของเราอย่างไร
- Code: detect-then-act post-order pattern
phantom fill คืออะไร
phantom fill คือเมื่อ CLOB API ตอบกลับคำสั่งของคุณด้วย status: "matched" แต่การโอน ERC-1155 บน chain ยังไม่ settled matcher ของ CLOB เร็วกว่า Polygon block production (~2 วินาทีต่อ block) อยู่ราว 2-5 วินาทีหลัง API แจ้ง match วอลเล็ตของคุณจะยังไม่ถือ token ที่ matcher บอกว่าคุณเป็นเจ้าของบน chain
bug ของ bot จะเกิดขึ้นเมื่อมีการกระทำต่อเนื่อง-โดยทั่วไปคือ GTC sell เพื่อวาง take-profit-ถูกเรียกในช่วงเวลานั้น CLOB จะตรวจ chain balance เห็นว่าเป็นศูนย์ แล้วปฏิเสธด้วย not enough balance / allowance: balance: 0, order amount: N ข้อความ error โทษ allowance แต่สาเหตุจริงคือ settlement lag
ครั้งแรกที่เจอ คุณจะคิดว่าเป็น bug ของ allowance แล้วเสียเวลาไปชั่วโมงหนึ่ง วิธีแก้เรียบง่าย: รอ, ตรวจสอบ, แล้วค่อยโพสต์
status=matched vs status=delayed vs status=posted
response ตอนวางคำสั่งจะมีฟิลด์ status ที่มี 3 ค่าหลักที่สำคัญ
matched: คำสั่ง match กับ book ทันที inventory จะ settle ภายใน 2-5 วินาที นี่คือสิ่งที่ FOK/FAK ส่งกลับเมื่อสำเร็จdelayed: matcher ไม่สามารถ settle แบบ synchronous ได้และนำ match ไปเข้า queue; พบไม่บ่อย โดยปกติบ่งบอกว่าระบบติดขัด ให้ปฏิบัติเหมือนmatchedสำหรับรูปแบบ wait + verifyposted(เรียกอีกอย่างว่าlive): คำสั่งถูกพักอยู่บน book โดยยังไม่ fill ถูกส่งกลับโดย GTC orders ที่ไม่ได้ match ทันที inventory ไม่ได้รับผลกระทบ ยังไม่ต้องมี action ต่อเนื่อง
กฎการตัดสินใจ: ถ้า status เป็น matched หรือ delayed อย่าวางคำสั่งต่อเนื่องใด ๆ ที่ต้องใช้ inventory ใหม่ จนกว่าคุณจะตรวจสอบการโอนบน chain แล้ว
Polling pattern: poll status ก่อนฉลอง
รูปแบบการตรวจสอบ: หลังจาก match สำเร็จ ให้ poll CTF balance จนกว่าจะสะท้อน token ใหม่ แล้วค่อยทำต่อ
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
การ settle ทั่วไป: 2-5 วินาทีเมื่อ network ปกติ, สูงสุดถึง 15 วินาทีเมื่อ Polygon congestion ตั้ง timeout 5 วินาทีจะครอบคลุม 95% ของกรณี; สำหรับ production ให้ตั้ง timeout เป็น 15 วินาทีและแจ้งเตือนเมื่อ timeout
สำหรับ bot ความถี่สูงที่รอแบบ block ไม่ได้ ทางเลือกคือ event-subscription: เฝ้าดู event TransferSingle ของ CTF สำหรับ proxy address ของคุณ แล้ว trigger downstream actions เมื่อได้รับ event วิธีนี้จะย้ายการรอไปไว้ใน queue แทนการ block strategy loop
FOK ในฐานะ anti-phantom-fill
การเลือก FOK แทน FAK เป็นการป้องกัน phantom-fill chaos ได้บางส่วน FOK จะ fill ทั้งคำสั่งทั้งหมดหรือส่งกลับ cancelled; ส่วน FAK อาจส่งกลับ filled_size ที่เป็น partial เมื่อ partial fill ถูกตามด้วย GTC sell ที่มีขนาดเท่าคำสั่งเดิม sell จะล้มเหลวเพราะทั้ง settlement-lag และ size mismatch-เป็น bug สองตัวที่ทับซ้อนกัน
ด้วย FOK ขนาดจะเป็นแบบ binary: either ขนาดเต็ม match แล้ว หรือไม่ก็ไม่มีอะไรเกิดขึ้น logic การโพสต์ต่อเนื่องจึงรู้เสมอว่าจะคาดหวังอะไร
สิ่งนี้ไม่ได้ตัดความจำเป็นของการรอออกไป-แม้แต่ FOK match ที่สมบูรณ์ก็ยังต้องอยู่ภายใต้หน้าต่างการ settle 2-5 วินาที แต่จะตัด divergence ใน bookkeeping ออกไปได้หนึ่งประเภท
Idempotent retries ด้วย client_order_id
network failure ระหว่างการวางคำสั่งสร้างสถานการณ์เลวร้ายที่สุด: HTTP call ของ bot timeout แต่คำสั่งอาจจะถูกส่งถึงหรือไม่ถึงก็ได้ การ retry แบบไม่คิดอาจทำให้ place ซ้ำ; แต่ถ้าไม่ retry ก็อาจพลาด position
วิธีแก้คือฟิลด์ client_order_id สำหรับการวางคำสั่ง สร้าง UUID แบบ deterministic ต่อคำสั่งที่ตั้งใจหนึ่งรายการ; ถ้าเซิร์ฟเวอร์เคยเห็น 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 ล้มเหลว, และไม่ retry เมื่อเป็น logical rejection การ dedup ฝั่งเซิร์ฟเวอร์ทำงานต่อ API key หนึ่งชุด และมีอายุประมาณ 5 นาที
เหตุการณ์จริงใน production: เราแก้ปัญหาของเราอย่างไร
จากบันทึก production ของเราเอง พฤษภาคม 2025 ช่วงเวลา 60 นาทีที่ trader bot วาง buy orders 22 รายการ, ทั้งหมด match, แต่มี GTC sells ที่ถูกยอมรับเพียง 14 รายการเท่านั้น อีก 8 position ไม่มี exit ถูกโพสต์
สาเหตุหลัก: bot โพสต์ GTC sell ภายใน 800ms หลัง buy match ซึ่งเร็วเกินไปก่อนที่ chain จะ confirm การโอน ERC-1155 CLOB ปฏิเสธด้วยข้อความ "balance: 0"; bot log error ไว้แต่ไม่ได้ retry 8 position จึงลอยไปสู่การ resolve โดยไม่มีการป้องกัน take-profit 3 รายการปิด out of the money; 1 รายการปิดที่ 0.99 ด้วยโชคช่วย
เราแก้โดย ship การ wait แบบ blocking 5 วินาทีระหว่าง buy fill และ GTC post บน token เดียวกัน ตรวจสอบแล้วด้วย 30 paper trades บวก 30 live trades; ตั้งแต่นั้นมามี balance-zero errors เป็นศูนย์
บทเรียนคือ: error path แบบเงียบมีต้นทุนสูงกว่าแบบดัง หลังจากนั้นเราทำให้ phantom-fill errors ทั้งหมด trigger Telegram alert เพื่อให้ future 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, network drop ชั่วคราว, ขนาด GTC ต่ำกว่าขั้นต่ำ ส่งกลับข้อมูลเพียงพอให้ strategy layer ตัดสินใจว่าอะไรควร retry, log, หรือ alert










