An acknowledgement is not a fill: the order state machine
The exchange telling you it received your order and the exchange telling you the order traded are different events. Bots that conflate them report profit that never existed.
Vizanix engineering · about the author
- SECTION
- Bot architecture
- PUBLISHED
- 2026-08-28
- CHAPTERS
- 6
- READ NEXT
- 3
- LANGUAGE
- written in English
The single most consequential line in an execution layer is the one that decides what “the order went through” means. Get it wrong and everything downstream — position tracking, risk, P&L, the backtest you compare against — is quietly built on a false premise.
Three different truths
- The HTTP response returned 200. Your request reached the venue and was accepted for processing. Nothing has traded.
- The order appears in the order book. It is live and resting. Still nothing has traded.
- An execution report arrived on the private stream. Something traded, for a specific quantity, at a specific price.
Only the third one changes your position. In our microstructure engine this is written into the design as a rule: an acknowledgement is not a fill, and fills come from the private order and execution stream, never from the response to the placement request.
The states you actually need
class OrderState(Enum):
PENDING = auto() # request built, not yet sent
SENT = auto() # in flight — outcome genuinely unknown
ACKED = auto() # venue accepted it; zero filled
PARTIAL = auto() # some quantity executed, remainder live
FILLED = auto() # fully executed
CANCELLED = auto() # no remainder live; may have partial fills
REJECTED = auto() # venue refused it; nothing executed
UNKNOWN = auto() # timeout or disconnect — must be resolved by queryUNKNOWN is the state most implementations omit, and it is the one that matters. A timeout is not a rejection. The order may be live. It may have filled. Treating a timeout as failure and re-sending is the classic way to end up with two positions.
Resolving the unknown
This is why every state-changing request carries a client-supplied order ID, generated before the first attempt and reused across retries. It gives you two things: the venue rejects a genuine duplicate instead of executing it, and you have a key to query by when you need to find out what happened.
async def place(intent) -> Order:
coid = client_order_id(intent) # deterministic, generated once
try:
resp = await api.create_order(intent, client_order_id=coid)
return Order(coid, OrderState.ACKED, resp)
except (Timeout, ConnectionError):
# We do NOT know whether the venue got it. Ask.
return await resolve_unknown(coid)
async def resolve_unknown(coid) -> Order:
for delay in (0.2, 0.5, 1.0, 2.0, 5.0):
await asyncio.sleep(delay)
found = await api.query_order(client_order_id=coid)
if found:
return Order.from_venue(found)
alert("order %s unresolved — halting symbol", coid)
raise Unresolved(coid)The last two lines are the part people leave out. If the state cannot be resolved, the correct behaviour is to stop trading that symbol and tell a human. A bot that continues with an unknown outstanding order is guessing about its own position.
Cancel is also a state change
Cancels get the same treatment. A cancel request that times out leaves the order in UNKNOWN, not in CANCELLED. Optimistically marking it cancelled is how a bot ends up with a resting order it believes does not exist — which then fills, at the worst possible moment, into a position nobody is managing.
And a cancel can race a fill. The order can execute in the microseconds between your decision to cancel and the venue processing it. The state machine must accept an execution report for an order it was cancelling, and treat the execution as authoritative.
Reconciliation is the backstop
State machines drift. Streams drop messages. Processes restart. The only reliable correction is periodic reconciliation against the venue: fetch open orders and positions, compare with local state, and resolve every difference in the venue's favour.
Run it after every reconnect, after every restart, and on a slow timer as a safety net. Log the discrepancies you find rather than silently fixing them — a reconciliation that starts finding differences regularly is telling you that something upstream is broken.
The rule, stated plainly
- Position changes on execution reports only.
- Every state-changing request carries a client order ID.
- Timeout means
UNKNOWN, andUNKNOWNis resolved by query. - Unresolvable state halts the symbol and pages a human.
- Reconcile against the venue on reconnect, restart and timer.
Five rules. Most execution bugs we are asked to fix are a violation of one of them.
This article describes engineering practice. It is not investment advice. Vizanix develops software and does not promise trading returns.