Polymarket Bot 教程 · 第 12 章,共 32 章

如何检测 Polymarket phantom fills(看起来已成交但实际上没有)的情况,实现幂等重试,区分 status=matched 与 rested-on-book,以及在临时故障中保持稳定运行。

本章涵盖内容

phantom fill 是 Polymarket 特有的一种故障模式:CLOB 已确认撮合成功,但链上尚未确认 ERC-1155 转账。随后在约 5 秒内发起的订单会被一个误导性的 "balance: 0" 错误拒绝。解决办法是幂等性和结算等待。本章是我们用真金白银换来的生产级操作手册。

  • 什么是 phantom fill
  • status=matched vs status=delayed vs status=posted
  • 轮询模式:先轮询状态,再庆祝
  • 用 FOK 抵御 phantom-fill
  • 结合 client_order_id 的幂等重试
  • 真实生产事故:我们是如何修复的
  • 代码:detect-then-act 的下单后处理模式

什么是 phantom fill

phantom fill 指的是:CLOB API 对你的订单返回 status: "matched",但链上 ERC-1155 转账尚未完成结算。CLOB matcher 的速度比 Polygon 出块更快(每个区块约 2 秒)。在 API match 之后约 2-5 秒内,你的钱包在链上还没有真正持有 matcher 声称你拥有的代币。

当 bot 在这个窗口内执行后续动作时,就会暴露出 bug-通常是为了挂止盈而发起的 GTC 卖单。CLOB 会检查链上余额,发现是零,并以 not enough balance / allowance: balance: 0, order amount: N 拒绝。报错把原因归咎于 allowance;真正原因是结算延迟。

第一次遇到这种情况时,你会以为是 allowance bug,然后白白浪费一个小时。解决方法很简单:等待、验证,然后再挂单。

status=matched vs status=delayed vs status=posted

下单响应中包含一个 status 字段,其中有三个关键值。

  • matched:订单已立即与订单簿撮合成功。资产会在 2-5 秒内结算。FOK/FAK 在成功时返回的就是这个状态。
  • delayed:matcher 无法同步结算,已将撮合排队。较少见;通常表示拥堵。就等待 + 验证模式而言,可以把它当成 matched 处理。
  • posted(也叫 live):订单挂在订单簿上,尚未成交。GTC 订单若没有立即撮合,就会返回该状态。资产不受影响;此时还不需要后续动作。

决策规则:如果 status 是 matcheddelayed,在确认链上转账之前,不要执行任何需要新库存的后续操作。

轮询模式:先轮询状态,再庆祝

验证模式是:在成功撮合后,轮询 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

典型结算时间:网络状况良好时为 2-5 秒,Polygon 拥堵时最长可达 15 秒。等待 5 秒可覆盖 95% 的情况;生产环境建议将超时设为 15 秒,并在超时后告警。

对于无法承受阻塞的高频 bot,另一种方法是事件订阅:监听 CTF 的 TransferSingle 事件,针对你的 proxy 地址,在收到事件后触发下游动作。这样可以把等待交给队列,而不是阻塞策略主循环。

用 FOK 抵御 phantom-fill

相较于 FAK,选择 FOK 可以部分防御 phantom-fill 乱象。FOK 要么整单成交,要么返回 cancelled;FAK 可能返回部分 filled_size。当部分成交后又按原始订单大小发起 GTC 卖单时,卖单会因结算延迟加上数量不匹配而失败-这是两个叠加的 bug。

使用 FOK 时,数量是二元的:要么全部成交,要么完全没有成交。后续挂单逻辑始终知道应该预期什么。

这并不能消除等待的必要性-即使是完美的 FOK 撮合,也仍然受 2-5 秒结算窗口影响。但它移除了一个账务偏差来源。

结合 client_order_id 的幂等重试

下单过程中的网络故障会制造最糟糕的场景:bot 的 HTTP 调用超时,但订单可能已经被接收,也可能没有被接收。盲目重试会导致重复下单;不重试则可能丢失仓位。

解决办法是下单时使用 client_order_id 字段。为每一笔预期订单生成一个确定性的 UUID;如果服务器之前见过这个 ID,它会返回已有订单的状态,而不是创建重复订单。

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,遇到传输失败就重试,遇到逻辑拒绝则不要重试。服务端去重按 API key 维度生效,持续约 5 分钟。

真实生产事故:我们是如何修复的

来自我们自己的生产日志,2025 年 5 月。一个 60 分钟窗口内,trader bot 发出了 22 个买单,全部成交,但只有 14 个 GTC 卖单被接受。8 个仓位没有挂出退出单。

根因:bot 在买单成交后 800ms 内就挂出了 GTC 卖单,远早于链上确认 ERC-1155 转账。CLOB 以 "balance: 0" 消息拒绝了请求;bot 记录了错误,但没有重试。8 个仓位在没有止盈保护的情况下静静地持有到结算。有 3 个最终以亏损结束;有 1 个靠运气以 0.99 结束。

修复方案是在任何同一 token 的买入成交与 GTC 挂单之间加入 5 秒阻塞等待。通过 30 笔纸面交易加 30 笔真实交易验证;此后再未出现 balance 为零的错误。

教训是:一个悄无声息的错误路径,比一个显眼的错误更昂贵。此后我们让所有 phantom-fill 错误都触发 Telegram 告警,这样未来的漂移模式会在几秒内被看见。

代码:detect-then-act 的下单后处理模式

生产环境中的先买后挂模式。

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 数量限制。它返回足够的信息,让策略层决定哪些要重试、哪些要记录、哪些要告警。

常见问题

Polymarket 上的 phantom fill 是什么?
phantom fill 指的是你的 bot 以为订单成交了,但交易所记录显示订单尚未成交(或仅部分成交)。这种情况通常发生在客户端代码把 HTTP 200 响应当作成交确认时,而实际上该响应只表示订单已被接受进撮合引擎。我们是吃过亏才学会这一点的-我们 trader 历史中的 commit 06deaef、8bb7761 和 e68a087 正是修复这个问题的。
如何避免 phantom fill?
三条规则:(1) 买单使用 FOK 订单-订单要么完全成交,要么完全取消,不会产生歧义。(2) 把任何非 matched 状态都视为未成交-轮询订单状态,直到 status=matched 或 amount_filled > 0。(3) 使用 client_order_id(V2 中为 clientOrderId)实现幂等性,这样重试不会重复成交。
status=delayed 是什么意思?
订单已经进入撮合引擎,但尚未完全撮合。它可能会在几秒内成交,也可能继续挂着。一定要轮询-如果 status 持续超过 5-10 秒仍为 delayed 且 amount_filled 为 0,就把它视为未成交,并考虑取消。
如何安全重试,避免重复成交?
为每一次逻辑上的交易尝试生成唯一的 client_order_id,并在每次重试时都带上它。交易所会按 client_order_id 去重,因此带相同 id 的重试订单会被拒绝为重复,而不会再次下单。实现示例:Python 的 OrderArgs.client_order_id,Node 的 CreateOrderOptions.clientOrderId。
我能信任订单接口返回的 200 OK 吗?
不能-200 OK 只表示“你的请求已被撮合引擎接受”,并不表示“你的订单已成交”。你必须在提交后通过 orderId 轮询订单状态,只有 status=matched(或 amount_filled > 0)才算真实成交。
如果 bot 在发送订单和看到响应之间崩溃了怎么办?
重启后,通过 SDK 查询未结订单和最近成交。与本地日志/状态进行对账-如果你在时间 T 发出过订单但没有任何记录,就查询 T 之后的订单,并通过 client_order_id 匹配。如果仍然找不到,那说明订单根本没有到达撮合引擎,可以安全重发。