#30655·ccxt

OKX algo orders: effective state produces an unverified full fill

Author: las83Created Sep 27, 2026Updated Sep 27, 2026
Labelsbug

Operating System

Linux

Programming Language

Python

CCXT Version

4.5.84

Description

Summary

OKX algo order responses (/api/v5/trade/order-algo, orders-algo-pending, orders-algo-history) carry no accFillSz field, and accFillSz is the only field okx.parse_order reads filled from. For an algo order it therefore passes filled: None to safe_order.

For state: 'effective', parse_order_status returns 'closed'. safe_order then infers filled = amount and remaining = 0, and computes cost from that inferred fill. effective means the trigger fired and a child order was placed. It says nothing about how much of that child executed. With a limit child (orderPx on create, ordPx in the response) the child may rest unfilled or fill partially, yet ccxt reports the algo order fully filled. The real fill belongs to the child order (ordId), which needs its own fetch_order.

A secondary consequence of the same missing field: ordType: 'trigger' orders that provably executed nothing (live, canceled, order_failed) parse with filled: None. A consumer cannot tell "unknown" from "zero".

Also affects myokx and okxus, which inherit okx.parse_order.

I searched existing issues for okx algo/trigger filled, accFillSz and order_failed and found no matching report.

Reproduction (no credentials needed)

python
import ccxt

ex = ccxt.okx()
ex.verbose = True  # parse_order makes no request, so this logs nothing
market = {"id": "SOL-USDT-SWAP", "symbol": "SOL/USDT:USDT", "base": "SOL", "quote": "USDT",
          "settle": "USDT", "type": "swap", "spot": False, "swap": True, "contract": True,
          "contractSize": 0.01, "precision": {}}
base = {"algoId": "1", "instId": "SOL-USDT-SWAP", "instType": "SWAP", "ordType": "trigger",
        "side": "sell", "posSide": "net", "sz": "100", "triggerPx": "90",
        "cTime": "1700000000000", "uTime": "1700000001000", "ordIdList": []}
cases = [
    ("effective", {"ordPx": "89", "actualSz": "100", "actualPx": "89", "ordId": "42"}),  # limit child
    ("effective", {"ordPx": "-1", "actualSz": "100", "actualPx": "", "ordId": "43"}),    # market child
    ("live", {"ordPx": "89", "actualSz": "0", "ordId": ""}),
    ("canceled", {"ordPx": "89", "actualSz": "0", "ordId": ""}),
    ("order_failed", {"ordPx": "-1", "actualSz": "0", "ordId": ""}),
]
for state, extra in cases:
    o = ex.parse_order(dict(base, state=state, **extra), market)
    print(state, extra["ordPx"], o["status"], o["filled"], o["remaining"], o["cost"])

verbose is on as the template asks, but this reproduction calls only the parser, so it makes no HTTP request and produces no verbose request/response log.

Output on ccxt 4.5.84 (columns: state, ordPx, status, filled, remaining, cost):

effective 89 closed 100.0 0.0 89.0
effective -1 closed 100.0 0.0 -1.0
live 89 open None None None
canceled 89 canceled None None None
order_failed -1 canceled None None None

The first row has a limit child (ordPx: 89), the second a market child (ordPx: -1).

Nothing in either effective payload reports an executed quantity. filled, remaining and cost are all inferred from status == 'closed'. In the market-child row, cost is computed from the -1 market-price placeholder.

Observed on OKX demo trading

fetch_order(id, symbol, {"trigger": True}) on reduce-only ordType: trigger stops with a market child (orderPx: -1). The table below is a summary of fields copied from the parsed order's info, not a raw transcript: verbose was not enabled during these reads. A sanitized verbose transcript can be captured and attached on request.

state actualSz ordId failCode ccxt status / filled
live "0" "" 0 open / None
canceled "0" "" 0 canceled / None
order_failed "0" "" 51169 canceled / None
effective "1" child order id 0 closed / 1.0
  • accFillSz was absent from every algo read; state was always present.
  • The order_failed row came from a stop that fired after its position had been closed. The reduce-only child was rejected (51169), so no child order exists.
  • In the effective row, the child order (ordId) read state: filled, accFillSz: 1. The inferred filled matched in this observation because the child was fully filled.

Comparison: binanceusdm

binance.parse_order defaults a missing executed quantity to '0', so a Binance conditional order that never fired (algoStatus NEW or CANCELED) parses with filled: 0.0, where the equivalent OKX read gives None. This only shows a different normalization of a missing quantity. It does not show that executed algo orders are reliably distinguishable on Binance.

Suggested fix

In okx.parse_order, for an algo order (algoId present, accFillSz absent):

  • state == 'effective': do not let safe_order infer filled = amount and remaining = 0. Leaving filled undefined is already the current behaviour and is not enough on its own, because the inference is driven by status == 'closed'. parse_order can stop the inference for this case, leaving filled and remaining undefined. It cannot populate filled from the child order, because that needs a second request. Given the one-request-per-unified-method guideline in CONTRIBUTING.md, the child's fill (ordId / ordIdList) is probably best left to the caller, who can fetch_order the child id from info.
  • state == 'partially_effective': leave filled and remaining undefined; it may have child fills.
  • ordType == 'trigger' with state in live, canceled, order_failed: filled = '0' (optionally corroborated by ordId == '' and actualSz == '0'). This rule is limited to the trigger orders observed above. Other algo types (conditional, oco, move_order_stop, iceberg, twap) were not tested.

Keying on the raw state matters: parse_order_status maps order_failed → canceled and partially_effective → open, so the unified status alone cannot make these distinctions.

References

Source links are pinned to ccxt master at 11e215fe.