Polymarket Bot Tutorial · Chương 12/32

Cách phát hiện phantom fill trên Polymarket (các lệnh trông như đã khớp nhưng thực tế chưa), triển khai idempotent retries, phân biệt status=matched với rested-on-book, và vượt qua các lỗi tạm thời.

Chương này bao gồm những gì

Một phantom fill là một kiểu lỗi đặc thù của Polymarket, trong đó CLOB xác nhận có match nhưng chain vẫn chưa xác nhận việc chuyển ERC-1155. Một lệnh tiếp theo trong khoảng ~5 giây sẽ bị từ chối với lỗi gây hiểu lầm "balance: 0". Cách khắc phục là idempotence và chờ settlement. Chương này là playbook vận hành mà chúng tôi đã trả giá bằng tiền thật để có được.

  • Phantom fill là gì
  • status=matched vs status=delayed vs status=posted
  • Polling pattern: poll status trước khi ăn mừng
  • FOK như một biện pháp chống phantom-fill
  • Idempotent retries với client_order_id
  • Sự cố production thực tế: chúng tôi đã sửa như thế nào
  • Code: detect-then-act post-order pattern

Phantom fill là gì

Phantom fill là khi CLOB API phản hồi lệnh của bạn với status: "matched" nhưng giao dịch chuyển ERC-1155 trên-chain vẫn chưa được settlement. CLOB matcher nhanh hơn việc tạo block trên Polygon (~2s mỗi block). Trong khoảng 2-5 giây sau khi API báo match, ví của bạn trên-chain chưa thực sự nắm giữ số token mà matcher nói là bạn sở hữu.

Lỗi bot xuất hiện khi một hành động tiếp theo - thường là một lệnh GTC sell để chốt take-profit - được chạy trong khoảng thời gian đó. CLOB kiểm tra số dư trên chain, thấy bằng 0, và từ chối với not enough balance / allowance: balance: 0, order amount: N. Thông báo lỗi đổ lỗi cho allowance; nguyên nhân thực sự là độ trễ settlement.

Lần đầu gặp tình huống này, bạn sẽ nghĩ đó là lỗi allowance và mất cả tiếng đồng hồ. Cách khắc phục rất đơn giản: chờ, xác minh, rồi mới post.

status=matched vs status=delayed vs status=posted

Phản hồi khi đặt lệnh có một trường status với ba giá trị quan trọng.

  • matched: lệnh đã khớp ngay với order book. Inventory sẽ được settlement trong 2-5 giây. Đây là trạng thái FOK/FAK trả về khi thành công.
  • delayed: matcher không thể settlement đồng bộ và đã xếp match vào hàng đợi. Hiếm gặp; thường cho thấy có congestion. Hãy xử lý như matched cho mục đích của pattern chờ + xác minh.
  • posted (còn gọi là live): lệnh đang nằm chờ trên order book và chưa khớp. Được trả về bởi các lệnh GTC không khớp ngay. Inventory không bị ảnh hưởng; chưa cần hành động tiếp theo.

Quy tắc quyết định: nếu status là matched hoặc delayed, đừng đặt bất kỳ lệnh tiếp theo nào cần inventory mới cho đến khi bạn đã xác minh việc chuyển trên chain.

Polling pattern: poll status trước khi ăn mừng

Pattern xác minh: sau khi khớp thành công, poll số dư CTF cho đến khi nó phản ánh token mới, rồi mới tiếp tục.

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 điển hình: 2-5 giây trong điều kiện mạng tốt, tối đa 15 giây khi Polygon bị congestion. Chờ 5 giây sẽ bao phủ 95% trường hợp; trong production hãy đặt timeout là 15 giây và alert khi quá timeout.

Với bot tần suất cao không thể block, một phương án khác là event-subscription: theo dõi sự kiện TransferSingle của CTF cho địa chỉ proxy của bạn và kích hoạt các hành động tiếp theo khi nhận được sự kiện. Cách này đẩy việc chờ sang queue thay vì chặn strategy loop.

FOK như một biện pháp chống phantom-fill

Chọn FOK thay vì FAK là một lớp phòng thủ một phần chống lại sự hỗn loạn do phantom-fill. FOK либо khớp toàn bộ lệnh hoặc trả về cancelled; FAK có thể trả về filled_size một phần. Khi một partial fill bị theo sau bởi lệnh GTC sell có size bằng lệnh gốc, lệnh sell sẽ thất bại do vừa settlement-lag vừa lệch size - hai lỗi cộng dồn.

Với FOK, size là nhị phân: hoặc toàn bộ size đã khớp, hoặc không gì cả. Logic post lệnh tiếp theo luôn biết chính xác điều gì để mong đợi.

Điều này không loại bỏ nhu cầu chờ - ngay cả một match FOK hoàn hảo cũng vẫn chịu ảnh hưởng của cửa sổ settlement 2-5 giây. Nhưng nó loại bỏ một nhóm divergence trong bookkeeping.

Idempotent retries với client_order_id

Lỗi mạng trong lúc đặt lệnh tạo ra kịch bản tệ nhất: lệnh HTTP của bot bị timeout, nhưng lệnh đó có thể đã hoặc chưa được nhận. Retry một cách ngây thơ có thể tạo lệnh trùng; không retry thì có thể làm mất một vị thế.

Cách khắc phục là trường client_order_id khi đặt lệnh. Hãy tạo một UUID mang tính xác định cho mỗi lệnh dự định gửi; nếu server đã thấy ID đó trước đây, nó sẽ trả về trạng thái của lệnh hiện có thay vì tạo bản sao.

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")

Pattern này là: tạo ID trước, retry khi lỗi transport, không retry khi bị từ chối logic. Cơ chế dedup phía server áp dụng theo từng API key, tồn tại khoảng 5 phút.

Sự cố production thực tế: chúng tôi đã sửa như thế nào

Từ nhật ký production của chính chúng tôi, tháng 5 năm 2025. Trong một khung 60 phút, trader bot đã đặt 22 lệnh mua, tất cả đều matched, nhưng chỉ có 14 lệnh GTC sell được chấp nhận. Tám vị thế không có lệnh thoát nào được post.

Nguyên nhân gốc: bot post lệnh GTC sell chỉ sau 800ms kể từ khi buy match, quá sớm so với thời điểm chain xác nhận chuyển ERC-1155. CLOB từ chối với thông báo "balance: 0"; bot ghi log lỗi nhưng không retry. Tám vị thế đó âm thầm đi tới lúc settlement mà không có lớp bảo vệ take-profit. Ba lệnh đóng ở trạng thái out of the money; một lệnh đóng ở mức 0.99 chỉ nhờ may mắn.

Bản sửa được triển khai bằng một khoảng chờ blocking 5 giây giữa bất kỳ buy fill nào và bất kỳ GTC post nào trên cùng token. Đã xác minh bằng 30 paper trades cộng 30 live trades; từ đó tới nay không còn lỗi balance-zero nào nữa.

Bài học: một đường lỗi im lặng đắt giá hơn nhiều so với một lỗi phát ra tiếng. Sau đó chúng tôi cho mọi lỗi phantom-fill kích hoạt Telegram alert, để một chế độ drift trong tương lai sẽ nhìn thấy trong vài giây.

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 này chịu được các failure mode phổ biến: phantom-fill, rớt mạng tạm thời, size GTC dưới mức tối thiểu. Nó trả về đủ thông tin để tầng chiến lược quyết định nên retry, log hay alert.

Các câu hỏi thường gặp

Phantom fill trên Polymarket là gì?
Phantom fill là khi bot của bạn tin rằng một lệnh đã khớp nhưng sàn ghi nhận là chưa khớp (hoặc mới khớp một phần). Điều này xảy ra khi code phía client coi phản hồi HTTP 200 là xác nhận, trong khi phản hồi đó chỉ có nghĩa là lệnh đã được chấp nhận vào matching engine. Chúng tôi đã học bài học này theo cách rất đắt giá - các commit 06deaef, 8bb7761, và e68a087 trong lịch sử trader của chúng tôi sửa đúng vấn đề này.
Làm sao để tránh phantom fill?
Ba nguyên tắc: (1) Dùng lệnh FOK cho buy - lệnh hoặc khớp hoàn toàn hoặc bị loại bỏ hoàn toàn, không mơ hồ. (2) Coi mọi status không phải matched là chưa khớp - poll trạng thái lệnh cho đến khi status=matched HOẶC amount_filled > 0. (3) Dùng client_order_id (clientOrderId trong V2) để đảm bảo idempotence, যাতে các lần retry không khớp trùng.
status=delayed nghĩa là gì?
Lệnh đã ở trong matching engine nhưng chưa được khớp hoàn toàn. Nó có thể khớp trong vài giây hoặc có thể nằm chờ. Luôn poll - nếu status vẫn delayed hơn 5-10 giây và amount_filled là 0, hãy coi như chưa khớp và cân nhắc hủy.
Làm sao retry an toàn mà không bị khớp trùng?
Tạo một client_order_id duy nhất cho mỗi lần thử giao dịch logic và truyền nó trong mọi lần retry. Sàn sẽ dedupe theo client_order_id, nên một lệnh được retry với cùng id sẽ bị từ chối như bản sao thay vì được đặt lại. Implementations: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Tôi có thể tin phản hồi 200 OK từ order endpoint không?
Không - 200 OK chỉ có nghĩa là "yêu cầu của bạn đã được matching engine chấp nhận," chứ không phải "lệnh của bạn đã khớp." Bạn phải poll trạng thái lệnh bằng orderId sau khi gửi và chỉ coi status=matched (hoặc amount_filled > 0) là một fill thực sự.
Nếu bot của tôi crash giữa lúc gửi lệnh và lúc thấy phản hồi thì sao?
Khi khởi động lại, hãy query open orders và recent fills thông qua SDK. Đối chiếu với diary/state cục bộ của bạn - nếu bạn đã gửi một lệnh tại thời điểm T nhưng không có bản ghi nào tồn tại, hãy query các lệnh từ sau T và khớp theo client_order_id. Nếu vẫn không thấy, thì lệnh đó chưa bao giờ tới matching engine và bạn có thể gửi lại an toàn.