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 + verify
  • posted (เรียกอีกอย่างว่า 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

คำถามที่พบบ่อย

phantom fill บน Polymarket คืออะไร?
phantom fill คือเมื่อ bot ของคุณคิดว่าคำสั่ง fill แล้ว แต่ exchange บันทึกว่ายังไม่ fill (หรือ fill บางส่วน) มันเกิดขึ้นเมื่อโค้ดฝั่ง client มอง HTTP 200 response เป็นการยืนยัน ทั้งที่ response นั้นหมายถึงแค่คำสั่งถูกส่งเข้า matching engine แล้วเท่านั้น เราเรียนรู้เรื่องนี้ด้วยวิธีที่เจ็บตัว - commits 06deaef, 8bb7761, และ e68a087 ในประวัติ trader ของเราแก้ตรงนี้พอดี
จะหลีกเลี่ยง phantom fills ได้อย่างไร?
มี 3 กฎ: (1) ใช้ FOK orders สำหรับ buy - คำสั่งจะ fill เต็มหรือหายไปเต็ม ๆ ไม่มีความกำกวม (2) ให้ถือว่าทุก status ที่ไม่ใช่ matched คือยังไม่ fill - poll order status จนกว่า status=matched หรือ amount_filled > 0 (3) ใช้ client_order_id (clientOrderId ใน V2) เพื่อ idempotence เพื่อให้ retry ไม่ทำให้ fill ซ้ำ
status=delayed หมายความว่าอะไร?
คำสั่งอยู่ใน matching engine แล้ว แต่ยัง match ไม่เต็ม มันอาจ match ภายในไม่กี่วินาทีหรืออาจค้างอยู่ก็ได้ ให้ poll เสมอ - ถ้า status ค้าง delayed นานกว่า 5-10 วินาทีและ amount_filled เป็น 0 ให้ถือว่ายังไม่ fill และพิจารณา cancel
จะ retry อย่างปลอดภัยโดยไม่ทำให้ fill ซ้ำได้อย่างไร?
สร้าง client_order_id ที่ไม่ซ้ำสำหรับการเทรดแต่ละครั้งที่เป็นตรรกะเดียวกัน และส่งค่าเดียวกันนี้ทุกครั้งที่ retry exchange จะ dedupe ด้วย client_order_id ดังนั้นคำสั่งที่ retry ด้วย id เดิมจะถูกปฏิเสธว่าเป็น duplicate แทนที่จะถูกวางซ้ำ การ implement: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId
ฉันเชื่อถือ HTTP 200 OK จาก order endpoint ได้ไหม?
ไม่ได้ - 200 OK หมายถึงแค่ "request ของคุณถูก matching engine รับแล้ว" ไม่ได้แปลว่า "order ของคุณ fill แล้ว" คุณต้อง poll order status ด้วย orderId หลัง submit และถือว่าเฉพาะ status=matched (หรือ amount_filled > 0) เท่านั้นที่เป็น real fill
ถ้า bot crash ระหว่างส่งคำสั่งกับเห็น response จะทำอย่างไร?
เมื่อเริ่มใหม่ ให้ query open orders และ recent fills ผ่าน SDK แล้ว reconcile กับ diary/state ในเครื่อง - ถ้าคุณส่งคำสั่งที่เวลา T แต่ไม่มี record อยู่ ให้ query orders ตั้งแต่เวลา T และจับคู่ด้วย client_order_id ถ้ายังหาไม่เจอ แสดงว่าคำสั่งไม่เคยถึง matching engine และคุณสามารถส่งใหม่ได้อย่างปลอดภัย