VIZANIXTrading Software Development
Bot architectureExecution8 min read

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

ARTICLE
8 minreading time
SECTION
Bot architecture
PUBLISHED
2026-08-28
CHAPTERS
6
READ NEXT
3
LANGUAGE
written in English
An engineering breakdown, not a rewrite of the docs.
STATE MACHINEACK ≠ FILLIDEMPOTENCYRECONCILE

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

python
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 query

UNKNOWN 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.

python
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

  1. Position changes on execution reports only.
  2. Every state-changing request carries a client order ID.
  3. Timeout means UNKNOWN, and UNKNOWN is resolved by query.
  4. Unresolvable state halts the symbol and pages a human.
  5. 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.

Blog

Read next

Want this running for you?

We write about what we build. If you need it built, get in touch — scoping is free.

Brief

Get a project estimate

Four questions and your contact. No deposit required to talk — if the job is not a fit, we say so straight away.

01What do you need
02Exchange
03Market
04Strategy
05Contacts

Prefer to write directly? Telegram @vx_ceo

Discuss a system