# Order state machine: FIX states, late fills and tests

URL: https://fincodelabs.com/en/blog/order-state-machine · Publisher: Fincode (Fincode Yazılım ve Teknoloji A.Ş.), Istanbul, Turkey · Category: Exchange APIs · Published: 2026-11-06 · Updated: 2026-11-06

Order state machine for trading code: FIX OrdStatus precedence, Binance, Bybit and Kraken statuses, unknown orders, late fills and tested Python.

**Short answer:** An **order state machine** tracks which status each order is in, from pending new to filled, canceled, rejected or expired, and decides which exchange reports may change it. Rank reports by cumulative filled quantity and status, so late messages cannot move an order backwards, and reconcile on any contradiction. In tests on 10,000 shuffled synthetic histories, last-message-wins failed 7,967; this design failed none.

This guide is for developers with a live integration and an order that looks stuck, filled twice, or canceled and filled at once. FIX values come from the FIX Trading Community's Order State Changes document (November 2023); Binance, Bybit and Kraken statuses from their documentation, read on 6 November 2026.

This is technical guidance, not a recommendation of any exchange or strategy; automated trading can lose money, and availability depends on your jurisdiction and each platform's terms. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.

## Order lifecycle states a trading client needs

A client needs nine states: six reported by the venue and three that exist only on your side.

**Order states in this guide; a final state never changes**

| State | Set by | Final | Meaning |
| --- | --- | --- | --- |
| `PENDING_NEW` | You | No | Sent, no answer yet |
| `UNKNOWN` | You | No | Answer lost; the order may or may not exist |
| `NEW` | Venue | No | Working, nothing filled |
| `PARTIALLY_FILLED` | Venue | No | Part filled, rest working |
| `PENDING_CANCEL` | You | No | Cancel sent; fills can still arrive |
| `FILLED` | Venue | Yes | All filled |
| `CANCELED` | Venue | Yes | Canceled, with or without fills |
| `EXPIRED` | Venue | Yes | Ended by time in force or a venue rule |
| `REJECTED` | Venue | Yes | Not accepted |

Pending cancel is not canceled, as the FIX document stresses, and unknown is not rejected. The [crypto order management system guide](https://fincodelabs.com/en/blog/crypto-order-management-system) places this inside a wider OMS.

## FIX OrdStatus values and order state precedence

FIX OrdStatus (tag 39) has 14 values in FIX Latest; when several apply at once, the one with the highest precedence is reported.

**OrdStatus precedence, highest first, from FIX Order State Changes (November 2023)**

| Precedence | OrdStatus value and name |
| ---: | --- |
| 11 | 6 Pending Cancel |
| 10 | E Pending Replace |
| 9 | 3 Done for Day |
| 8 | B Calculated |
| 7 | 2 Filled |
| 6 | 7 Stopped |
| 5 | 9 Suspended |
| 4 | 4 Canceled; C Expired |
| 3 | 1 Partially Filled |
| 2 | 0 New; 8 Rejected; A Pending New |
| 1 | D Accepted for Bidding |

- **ExecType (150) is the event**; OrdStatus is the status after it.
- **Fills during a pending cancel keep OrdStatus 6.** In scenario B.1.c the completing fill is reported as Pending Cancel and Filled arrives on the Order Cancel Reject (reason 0, too late to cancel), so derive state from CumQty and LeavesQty.
- **Rejected can follow New**, and Pending New is sent only in reply to a status request.
- **Final status, not final quantity:** scenario J.1.b busts a 5,000 execution on a canceled order, then reports a new 4,000 one.

The [QuickFIX Python guide](https://fincodelabs.com/en/blog/quickfix-python) runs a FIX session on localhost.

## Order status names on Binance, Bybit and Kraken

Venues name statuses differently, so map them in one adapter and raise an error on any unknown name.

**Statuses as documented on 6 November 2026; the mapping is this guide's choice**

| State here | Binance Spot | Bybit V5 | Kraken WebSocket v2 |
| --- | --- | --- | --- |
| `NEW` | NEW; PENDING_NEW (order lists) | New, Untriggered, Triggered | pending_new, new |
| `PARTIALLY_FILLED` | PARTIALLY_FILLED | PartiallyFilled | partially_filled |
| `FILLED` | FILLED | Filled | filled |
| `CANCELED` | CANCELED | Cancelled, PartiallyFilledCanceled, Deactivated | canceled |
| `EXPIRED` | EXPIRED, EXPIRED_IN_MATCH | None listed | expired |
| `REJECTED` | REJECTED | Rejected | None listed |
| `PENDING_CANCEL` | PENDING_CANCEL (currently unused) | None listed | None listed |

- **One event, three names.** An IOC remainder is `EXPIRED` on Binance, `canceled` on Kraken and Canceled in FIX (I.1.b); Kraken also cancels, not rejects, a pending order that fails validation. See [time in force](https://fincodelabs.com/en/blog/time-in-force).
- **Names hide fills.** Bybit's `Cancelled` derivatives orders may have an executed quantity, so read cumulative quantity, such as `z` in Binance's `executionReport`.
- **Kraken sends floats and trade-only IDs.** Its executions reference types `cum_qty` as a float and sends `exec_id` on trades only; its lifecycle guide's example shows `filled_qty` instead. Use `json.loads(text, parse_float=Decimal)`: by default Python 3.11.3 read `1e400` as `inf` and `1e-400` as `0.0`. Never deduplicate on a missing ID.

For integration work in general, see [Fincode's exchange API integration page](https://fincodelabs.com/en/services/exchange-api-integration); naming Binance, Bybit or Kraken here does not mean Fincode integrates them or has built on them.

## Order state transitions: legal, stale and contradictory

A report is applied when it moves the order forward, ignored when it is older than what you know, and escalated when it contradicts a final state.

Forward means a higher pair of cumulative quantity and rank: `NEW` 1, `PARTIALLY_FILLED` 2, final states 3. Quantity orders shuffled reports; rank breaks ties, such as a fill and a cancel confirmation both at 0.4. Keeping the maximum makes repeats harmless.

**Transitions in the tested sample**

| From | Event | To |
| --- | --- | --- |
| PENDING_NEW | Response lost | UNKNOWN |
| PENDING_NEW, UNKNOWN | Valid venue report | That report's status |
| NEW, PARTIALLY_FILLED | Cancel sent | PENDING_CANCEL |
| PENDING_CANCEL | Partial fill | PENDING_CANCEL, quantity rises |
| PENDING_CANCEL | Cancel rejected | NEW or PARTIALLY_FILLED |
| Any non-final | Final report | That final state |
| Any | Older or repeated report | No change |
| Final | Higher quantity, or another final status | Stop and reconcile |

A late `NEW` after `FILLED` is usually a delayed acknowledgment, not an error. Alarm on contradictions and impossible content: `FILLED` below the order quantity, a bad quantity, or an unmapped status.

## Unknown order status after a timeout or lost response

After a timeout the order may be working, filled or never received, so look it up under the same client order ID and act on neither guess.

Binance's Spot documentation says an HTTP 5XX leaves the execution status unknown, possibly a success, and that its `-1007` timeout after 10 seconds does not always mean failure; check the user data stream, then query.

1. Keep the order `UNKNOWN` and block orders that would duplicate its exposure.
2. Watch the [user data stream](https://fincodelabs.com/en/blog/binance-user-data-stream) and query by client order ID; any valid report ends `UNKNOWN`.
3. Never resend under a new ID; see the [client order ID guide](https://fincodelabs.com/en/blog/client-order-id).
4. Distrust an early not-found answer: Binance re-checks `recvWindow` before forwarding to the matching engine, but its timing section does not say how long a request may wait there.

[Fincode's order gateway reference](https://fincodelabs.com/en/reference/order-gateway) runs this lost-response case against a local mock exchange.

## Late fills after a cancel request

A cancel races the matching engine, so fills can arrive after you send it, in any order across channels.

Three FIX cancel scenarios matter here: a fill before the cancel is accepted, so the cancel is rejected as too late (B.1.c); an acknowledgment crossing the cancel (B.1.e); and a cancel for an unknown order (B.1.f). Record nothing as canceled until the venue says so.

> **Hypothetical example.** You sell 1.0 unit, 0.5 has filled, and you cancel. The venue fills the rest first and rejects the cancel; the reject arrives before the fill. The order goes `PENDING_CANCEL` at 0.5, `PARTIALLY_FILLED` at 0.5, `FILLED` at 1.0. Code that wrote `CANCELED` on sending would record 0.5 sold, not 1.0, until [position reconciliation](https://fincodelabs.com/en/blog/position-reconciliation) caught it.

The sample does not model trade busts (FIX ExecType Trade Cancel): it raises `Broken` when a final report shows less than it knows, as in J.1.b, but ignores a lower quantity on a non-final report. Add a correction event if your venue busts trades; Bybit's `BustTrade` execution type is a takeover liquidation, not a bust.

## Tested Python: an order state machine with property tests

About 60 lines of standard-library Python, tested on 10,000 synthetic histories with repeats, shuffling, lost responses and cancel races.

Setup: Python 3.11.3 on Windows, 6 November 2026, seeds 0 to 9,999, no network or keys. `osm.py`:

```python
from decimal import Decimal

RANK = {"NEW": 1, "PARTIALLY_FILLED": 2,
        "FILLED": 3, "CANCELED": 3, "EXPIRED": 3, "REJECTED": 3}
FINAL = {s for s, r in RANK.items() if r == 3}

class Broken(Exception):
    """A report contradicts what is known: stop this order and reconcile."""

def dec(x):
    if isinstance(x, float):
        raise TypeError("pass quantities as str or Decimal, not float")
    try:
        return Decimal(x)
    except ArithmeticError:                 # "" or "abc": NaN, rejected below
        return Decimal("NaN")

class Order:
    def __init__(self, qty):
        self.qty, self.filled, self.status = dec(qty), Decimal(0), None
        if not (self.qty.is_finite() and self.qty > 0):
            raise ValueError(f"bad order quantity {qty}")
        self.timed_out = self.cancel_sent = False

    @property
    def state(self):
        if self.status is None:             # nothing heard from the venue
            return "UNKNOWN" if self.timed_out else "PENDING_NEW"
        if self.cancel_sent and self.status not in FINAL:
            return "PENDING_CANCEL"
        return self.status

    def timeout(self):                      # new-order response lost
        if self.status is None:
            self.timed_out = True

    def send_cancel(self):                  # True: a cancel may go out now
        ok = self.state in ("NEW", "PARTIALLY_FILLED")
        self.cancel_sent |= ok
        return ok

    def cancel_rejected(self):              # e.g. too late to cancel
        self.cancel_sent = False

    def report(self, status, cum):
        cum = dec(cum)
        if status not in RANK or not (cum.is_finite() and 0 <= cum <= self.qty):
            raise Broken(f"bad report {status} {cum}")
        if (status in ("NEW", "REJECTED") and cum) or \
           (status == "FILLED" and cum < self.qty) or \
           (status == "PARTIALLY_FILLED" and not 0 < cum < self.qty):
            raise Broken(f"{status} with cumulative quantity {cum}")
        if self.status:
            old, new = (self.filled, RANK[self.status]), (cum, RANK[status])
            if new == old and status == self.status:
                return "duplicate"
            if new == old or (self.status in FINAL and cum > self.filled) \
                    or (status in FINAL and cum < self.filled):
                raise Broken(f"{self.status} {self.filled} then {status} {cum}")
            if new < old:
                return "stale"
        self.status, self.filled = status, cum
        return "applied"
```

`test_osm.py` checks that filled quantity never falls, a final state never changes and the end state matches the venue's:

```python
import random
from decimal import Decimal
from osm import Order, Broken, FINAL

class LastMessageWins(Order):               # the common bug, for comparison
    def report(self, status, cum):
        self.status, self.filled = status, Decimal(cum)

def venue(rng, qty):
    """What the mock venue really did to one order: (status, cum qty)."""
    steps, cum = [("NEW", "0")], Decimal(0)
    while rng.random() < 0.6 and cum + Decimal("0.3") < qty:
        cum += Decimal(rng.randint(1, 3)) / 10
        steps.append(("PARTIALLY_FILLED", str(cum)))
    end = rng.choice(["FILLED", "CANCELED", "EXPIRED"] + ["REJECTED"] * (cum == 0))
    steps.append((end, str(qty) if end == "FILLED" else str(cum)))
    return steps[rng.random() < 0.2:]       # sometimes no NEW report

def run(cls, seed, qty=Decimal("1")):
    rng = random.Random(seed)
    truth = venue(rng, qty)
    msgs = truth + rng.choices(truth, k=rng.randint(0, 3))   # repeats
    rng.shuffle(msgs)                       # REST and stream, any order
    events = [("report", m) for m in msgs]
    for local in ("timeout", "cancel"):
        events.insert(rng.randint(0, len(events)), (local, None))
    venue_rejects_cancel = truth[-1][0] in ("FILLED", "EXPIRED", "REJECTED")
    o, i = cls(qty), 0
    while i < len(events):
        kind, msg = events[i]
        before = (o.state, o.filled)
        if kind == "report":
            o.report(*msg)
        elif kind == "timeout":
            o.timeout()
        elif kind == "cancel" and o.send_cancel() and venue_rejects_cancel:
            events.insert(rng.randint(i + 1, len(events)), ("cancel_rejected", None))
        elif kind == "cancel_rejected":
            o.cancel_rejected()
        stayed = before[0] not in FINAL or o.state == before[0]
        if o.filled < before[1] or not stayed:
            return False, events[:i + 1]
        i += 1
    return (o.state, o.filled) == (truth[-1][0], Decimal(truth[-1][1])), events

for cls in (Order, LastMessageWins):
    bad = [s for s in range(10_000) if not run(cls, s)[0]]
    print(f"{cls.__name__}: {len(bad):,} of 10,000 runs wrong")
print("shortest failure:", min((run(LastMessageWins, s)[1] for s in bad), key=len))

done = Order("1")
done.report("CANCELED", "0.4")
for case in [("NEW", "0"), ("PARTIALLY_FILLED", "0.6"), ("EXPIRED", "0.4"),
             ("PartiallyFilledCanceled", "0.4"), ("FILLED", "1e400"),
             ("FILLED", ""), ("NEW", 0.0)]:
    try:
        print(case, "->", done.report(*case))
    except (Broken, TypeError) as e:
        print(case, "->", type(e).__name__, e)
```

Output of `python test_osm.py`:

```
Order: 0 of 10,000 runs wrong
LastMessageWins: 7,967 of 10,000 runs wrong
shortest failure: [('report', ('PARTIALLY_FILLED', '0.3')), ('report', ('NEW', '0'))]
('NEW', '0') -> stale
('PARTIALLY_FILLED', '0.6') -> Broken CANCELED 0.4 then PARTIALLY_FILLED 0.6
('EXPIRED', '0.4') -> Broken CANCELED 0.4 then EXPIRED 0.4
('PartiallyFilledCanceled', '0.4') -> Broken bad report PartiallyFilledCanceled 0.4
('FILLED', '1e400') -> Broken bad report FILLED 1E+400
('FILLED', '') -> Broken bad report FILLED NaN
('NEW', 0.0) -> TypeError pass quantities as str or Decimal, not float
```

- **The tests catch real bugs.** Mutating the sample to apply stale reports failed 7,967 runs, as last-message-wins did; treating only lower quantity as stale failed 4,225.
- **The paths ran.** An instrumented copy counted 7,897 repeated reports, 20,137 stale ones, 2,026 unknown orders later settled and 1,479 cancel rejects, 821 after the final report.
- **Not covered:** amendments, busts, order lists, several cancels at once, restarts and real venues.

## Frequently asked questions

### What are the order lifecycle states?

Pending new, new, partially filled, pending cancel, filled, canceled, rejected and expired, plus unknown after a lost response. Filled, canceled, rejected and expired are final.

### What is the difference between OrdStatus and ExecType?

ExecType (tag 150) says why a FIX ExecutionReport was sent; OrdStatus (tag 39) is the order's status after it.

### What should a bot do when an order status is unknown?

Block duplicate exposure and look the order up by client order ID, on the stream and by query. Never resend it under a new ID.

### Should an order state machine raise errors on illegal transitions?

Only on contradictions, such as a fill above a final report's quantity; log and ignore reports that are merely older.

## Sources

- [FIX Trading Community: FIX Application Layer, Order State Changes (FIX Latest as of EP284, November 2023)](https://www.fixtrading.org/wp-content/uploads/download-manager-files/FIX-Latest-as-of-EP284-Order-State-Changes.pdf)
- [FIX Latest OrdStatus code set, tag 39, on Orchimate (fiximate.fixtrading.org redirects there)](https://orchimate.org/fixtrading/fix-latest/codeSets/OrdStatusCodeSet)
- [Binance Spot API documentation: Enum definitions (order status, execution types)](https://github.com/binance/binance-spot-api-docs/blob/master/enums.md)
- [Binance Spot API documentation: REST API (HTTP return codes, -1007 timeout, timing security)](https://github.com/binance/binance-spot-api-docs/blob/master/rest-api.md)
- [Binance Spot API documentation: User Data Stream (executionReport fields)](https://github.com/binance/binance-spot-api-docs/blob/master/user-data-stream.md)
- [Bybit V5 API documentation: Enums (orderStatus, execType)](https://bybit-exchange.github.io/docs/v5/enum#orderstatus)
- [Kraken API documentation: WebSocket v2 executions channel](https://docs.kraken.com/exchange/api-reference/spot-websocket-v2/executions)
- [Kraken documentation: Order lifecycle guide](https://docs.kraken.com/exchange/guides/general/order-lifecycle)
- [Capital Markets Law No. 6362, including Article 99/A (Turkish), mevzuat.gov.tr](https://www.mevzuat.gov.tr/mevzuatmetin/1.5.6362.pdf)

> This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the [editorial policy](https://fincodelabs.com/en/editorial-policy).

---

Published by Fincode (Fincode Yazılım ve Teknoloji A.Ş.), a software company in Istanbul, Turkey: https://fincodelabs.com/en/blog/order-state-machine

Cite as: Fincode, "Order state machine: FIX states, late fills and tests", updated November 6, 2026, https://fincodelabs.com/en/blog/order-state-machine
