Polymarket Bot Tutorial · 32개 중 12장
Polymarket phantom fill(체결된 것처럼 보이지만 실제로는 아닌 주문)을 감지하고, idempotent retries를 구현하며, status=matched와 rested-on-book을 구분하고, 일시적 장애를 버티는 방법.
이 장에서 다루는 내용
phantom fill은 Polymarket 고유의 failure mode로, CLOB는 match를 인정했지만 chain이 아직 ERC-1155 transfer를 확인하지 않은 상태를 말합니다. 그 후 약 5초 이내에 후속 주문을 넣으면 오해의 소지가 있는 "balance: 0" error로 거절됩니다. 해결책은 idempotence와 settlement wait입니다. 이 장은 우리가 실제 돈을 들여 얻은 production playbook입니다.
- phantom fill이란 무엇인가
- status=matched vs status=delayed vs status=posted
- Polling pattern: 축하하기 전에 status를 poll하기
- FOK를 anti-phantom-fill로 사용하기
- client_order_id를 이용한 idempotent retries
- 실제 production incident: 어떻게 해결했는가
- Code: detect-then-act post-order pattern
phantom fill이란 무엇인가
phantom fill은 CLOB API가 주문에 대해 status: "matched"로 응답했지만, on-chain ERC-1155 transfer는 아직 settle되지 않은 상태입니다. CLOB matcher는 Polygon block production보다 빠릅니다(블록당 약 2초). API match 이후 대략 2~5초 동안, wallet에는 matcher가 소유했다고 말하는 token이 on-chain으로는 아직 없습니다.
bot bug는 보통 이 창(window) 안에서 후속 동작이 실행될 때 발생합니다. 일반적으로 take-profit을 올리기 위해 GTC sell을 넣는 경우입니다. CLOB는 chain balance를 확인하고 zero를 발견한 뒤 not enough balance / allowance: balance: 0, order amount: N로 거절합니다. error message는 allowance를 탓하지만, 실제 원인은 settlement lag입니다.
처음 겪으면 allowance bug라고 생각하고 한 시간을 허비합니다. 해결책은 간단합니다: 기다리고, 확인하고, 그다음 post하세요.
status=matched vs status=delayed vs status=posted
order placement response에는 중요한 3가지 value를 가진 status field가 포함됩니다.
matched: 주문이 book과 즉시 matched되었습니다. inventory는 2~5초 내 settle됩니다. FOK/FAK가 성공했을 때 반환하는 값입니다.delayed: matcher가 synchronous하게 settle하지 못하고 match를 queue에 넣었습니다. 드물며, 보통 congestion을 의미합니다. wait + verify pattern에서는matched와 비슷하게 취급하세요.posted(live라고도 함): 주문이 book에 resting 중이며 아직 filled되지 않았습니다. 즉시 match되지 않은 GTC order가 반환합니다. inventory에는 영향이 없으며, 아직 후속 조치가 필요하지 않습니다.
판단 규칙: status가 matched 또는 delayed라면, chain transfer를 검증하기 전까지 새 inventory가 필요한 후속 주문을 넣지 마세요.
Polling pattern: 축하하기 전에 status를 poll하기
verification pattern: match에 성공한 뒤 CTF balance가 새 token을 반영할 때까지 poll한 다음 진행합니다.
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초, Polygon congestion 시 최대 15초까지 걸립니다. 5초 wait이면 95%의 경우를 커버합니다. production에서는 timeout을 15초로 설정하고 timeout 시 alert를 보내세요.
blocking이 부담스러운 고빈도 bot의 경우 대안으로 event-subscription을 사용할 수 있습니다. CTF의 TransferSingle event를 proxy address에 대해 감시하고, 수신 시 downstream action을 트리거합니다. 이렇게 하면 strategy loop를 막지 않고 wait를 queue로 넘길 수 있습니다.
FOK를 anti-phantom-fill로 사용하기
FAK 대신 FOK를 선택하는 것은 phantom-fill chaos에 대한 부분적인 방어책입니다. FOK는 주문 전체가 채워지거나 cancelled를 반환합니다. FAK는 부분 체결을 의미하는 filled_size를 반환할 수 있습니다. 부분 체결 뒤에 원래 주문 크기에 맞춘 GTC sell이 이어지면, settlement lag와 size mismatch라는 두 가지 버그가 겹쳐서 sell이 실패합니다.
FOK에서는 size가 이진적입니다. 전체 size가 matched되었거나, 아무것도 되지 않았거나 둘 중 하나입니다. 따라서 후속 posting logic은 항상 무엇을 예상해야 하는지 알고 있습니다.
이것이 wait의 필요성을 없애는 것은 아닙니다. 완벽한 FOK match라도 2~5초 settlement window의 영향을 받습니다. 하지만 bookkeeping divergence의 한 종류는 제거해 줍니다.
client_order_id를 이용한 idempotent retries
order placement 중 network failure가 발생하면 최악의 상황이 생깁니다. bot의 HTTP call은 timeout되었지만, 주문이 수신되었는지는 알 수 없습니다. 무작정 retry하면 중복 주문이 될 수 있고, retry하지 않으면 position을 놓칠 수 있습니다.
해결책은 order placement의 client_order_id field입니다. 의도한 각 주문마다 deterministic UUID를 생성하세요. server가 해당 ID를 이미 본 적이 있다면, duplicate를 만들지 않고 기존 주문의 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를 생성하고, transport failure에 대해서만 retry하며, logical rejection에는 retry하지 않습니다. server-side dedup은 API key별로 동작하며 약 5분 동안 유지됩니다.
실제 production incident: 어떻게 해결했는가
우리 자체 production diary에서 가져온 사례입니다. 2025년 5월의 60분 동안 trader bot이 22개의 buy order를 넣었고, 모두 matched되었지만 GTC sell은 14개만 승인되었습니다. 8개 position에는 exit가 게시되지 않았습니다.
원인은 bot이 buy match 직후 800ms 만에 GTC sell을 올렸기 때문입니다. chain이 ERC-1155 transfer를 확인하기 전이었습니다. CLOB는 "balance: 0" message로 거절했고, bot은 error를 기록했지만 retry하지 않았습니다. 8개 position은 take-profit 보호 없이 조용히 resolution까지 흘러갔습니다. 3개는 out of the money로 종료되었고, 1개는 운 좋게 0.99에 종료되었습니다.
해결책은 동일 token에서 buy fill과 GTC post 사이에 5초 blocking wait를 두는 방식으로 배포되었습니다. 30번의 paper trade와 30번의 live trade로 검증했으며, 그 이후 balance-zero error는 0건입니다.
교훈: 조용한 error path는 시끄러운 error path보다 훨씬 더 비쌉니다. 이 사건 이후 우리는 모든 phantom-fill error가 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은 phantom-fill, 일시적 network drop, minimum 이하의 GTC size 같은 일반적인 failure mode를 견뎌냅니다. strategy layer가 무엇을 retry하고, 무엇을 log하며, 무엇을 alert할지 결정할 수 있도록 충분한 정보를 반환합니다.














