ASK KNOX
beta
LESSON 264

FOK vs GTC Execution Semantics

Fill-or-Kill attempts first for execution certainty and speed, Good-Til-Canceled is the fallback for resting orders at the maker price. The HE-06 bug: status=live means pending on the book, not filled. How to distinguish resting orders from actual fills.

8 min read·Prediction Market Mechanics

A fill-detection bug (internally tracked as HE-06) looked like a ghost trade. Agent Framework logged "order placed" and moved on to the next signal. The wallet balance showed a small debit. But the position never appeared in the resolver, and the PnL never materialized.

The root cause was a single line of logic: Agent Framework was treating status=live as a successful fill.

The Semantics

Polymarket's order placement endpoint returns a JSON blob with a status field. The documented placement statuses are live, matched, delayed, and unmatched — and only one of them means the order actually traded:

StatusMeaning
liveOrder placed and resting on the book. No fill yet. Waiting for counterparty.
matchedOrder was marketable and crossed against resting orders at placement. It traded.
delayedOrder is marketable, but subject to a matching delay. Not confirmed yet — keep polling.
unmatchedOrder was marketable, but matching failed or was deferred at placement. No fill.

There is no filled, partial, or expired placement status. Fill quantity lives on the order object itself — the size_matched field you read when you poll the order — and a partially filled order is simply a live order with a non-zero size_matched. Note that a marketable FOK-first flow will routinely see delayed and unmatched, not just live and matched, so the state machine has to handle all four.

status=live is the default state after posting a new GTC order. It is a healthy, normal response — but it is not a confirmation of trade. Treating it as one was the HE-06 bug.

FOK vs GTC

Polymarket supports both Fill-or-Kill (FOK) and Good-Til-Canceled (GTC) semantics via the order_type field on order creation.

  • FOK: Attempt to fill the entire order immediately at the specified price. If the book cannot absorb the full size at that price, the order is killed. Either you get a full fill right now, or you get nothing. No resting.
  • GTC: Post the order to the book and let it sit. Matches happen when a counterparty crosses the spread. The order stays live until it fills, is canceled, or hits its expiration.

Agent Framework's execution strategy is FOK-first:

  1. Query the orderbook depth. Compute the walked price at intended size.
  2. If walked price is acceptable: attempt FOK at the aggressive price. If it fills, done.
  3. If FOK fails (insufficient depth): fall back to GTC at a more conservative maker price. Let the order rest.
  4. Poll the order status until it transitions out of live.

The rationale for FOK-first is execution certainty and speed — a successful immediate fill removes the uncertainty of a resting order waiting for a counterparty. FOK is a taker order: it crosses the spread and fills right now, or it is killed immediately. It never rests on the book. The rationale for GTC fallback is execution reliability — thin books will reject a lot of FOK attempts and you need a path that eventually trades. GTC posts a resting maker order at a more conservative price, accepting slower execution in exchange for a fill once a counterparty crosses the spread. (Fee schedules in CLOBs structurally favor makers, so a resting GTC order would earn the maker side of any fee — though Polymarket's current schedule is 0% on both maker and taker for most markets, so the FOK/GTC choice is driven by execution semantics, not fees.)

Inline Diagram — Decision Tree

The HE-06 Fix

The bug fix was a state machine:

# Buggy
resp = clob.place_order(order)
if resp.get("status") == "live":  # WRONG — this is pending, not filled
    mark_position_as_filled(signal_id)

# Correct
resp = clob.place_order(order)
order_id = resp["id"]

if resp.get("status") == "unmatched":
    # Marketable but matching failed at placement — treat as not executed.
    clob.cancel_order(order_id)
    mark_signal_as_unexecuted(signal_id)
else:
    # "live", "delayed", and even "matched" all go through the polling loop —
    # confirm the actual fill size before marking any position.
    for attempt in range(30):
        order = clob.get_order(order_id)
        size_matched = float(order.get("size_matched") or 0)
        if order["status"] == "matched" or size_matched >= order_size:
            # There is no avg_price field on the order. Use the order's price
            # field (the limit you signed), or VWAP the associated trades for
            # the true average fill price on multi-level walks.
            mark_position_as_filled(signal_id, fill_price=float(order["price"]),
                                    size=size_matched)
            break
        if order["status"] == "canceled":
            mark_signal_as_unexecuted(signal_id)
            break
        time.sleep(2)
    else:
        # Still resting after 60s — cancel and retry with GTC maker order
        clob.cancel_order(order_id)
        place_gtc_maker(signal_id)

The Rule

Order creation does not equal order execution. FOK attempts first, GTC falls back, polling confirms the fill. Treat every status=live response as "not yet" and build the state machine that resolves it.