OKX algo orders: effective state produces an unverified full fill · Issue #30655 · ccxt/ccxt · GitHub
Skip to content

OKX algo orders: effective state produces an unverified full fill #30655

Open

Description

@las83

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)

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.

Activity

  1. carlotestor commented on Sep 27, 2026

    @carlotestor
    Collaborator

    Thanks for the detailed report. It reproduces on current master (11e215fe). I ran the same payloads through okx.parseOrder in JS and got the same result: effective becomes closed with filled = amount, remaining = 0, and cost is inferred. live and order_failed give filled: undefined.

    The root cause matches your analysis:

    • ts/src/okx.ts parseOrder reads filled only from accFillSz. Algo payloads don't have that field.
    • parseOrderStatus maps effective to closed, and then safeOrder (ts/src/base/Exchange.ts, the status === 'closed' branches) fills in filled and remaining.

    There is one more symptom from the same code path. For a market child, price comes from safeString2 (order, 'px', 'ordPx'), so the unified price is the -1 placeholder. That's why the second row shows cost: -1.

    A fix has to decide how to report effective without a second request. Options include keeping status: 'closed' but blocking the inference, or treating ordPx == '-1' as "no price". That choice belongs to the maintainers, so I'm not opening a patch right now. For now, the reliable way to get the executed quantity is to fetchOrder the child info['ordId'] (or each id in ordIdList) without trigger: true.

  2. added a commit that references this issue on Sep 30, 2026
    b42bae0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions