The order lifecycle

What happens between POST /api/ecosystem/order and a closed row — the four order types, the three statuses, the inOrder hold, price-time priority, cross-process placement and the ways an order gets stuck.

10 min readUpdated 6 August 2026orders, matching-engine, scylladb, holds, cancellation

Three support questions account for most of the traffic an operator gets about this addon: why did my customer's order never fill, why is their balance locked with no open order, and what does CLOSED mean. All three are answered by the same sequence, so this page walks it once, end to end.

Everything here concerns ecosystem markets only — orders placed through POST /api/ecosystem/order and stored in the trading keyspace. Exchange (SPOT) orders are a passthrough to a third-party venue and follow none of it.

Two gates before anything is written

Places an order on an ecosystem market

KYC feature trade. The handler asserts it before it branches on order type, so it applies to conditional orders too. If your levels do not grant the trade feature, no ecosystem order can be placed by anyone the policy applies to. The feature toggles live in the level builder at Users → Compliance & Verification → Verification Levels (/admin/crm/kyc/level) — see KYC: levels, features and the application queue.

A per-user throttle of 120 orders per minute. This route uses the trade-grade limiter, not the checkout-grade one that governs e-commerce and ICO purchases (5 per 15 minutes) — a limit ladder or a quick re-quote would otherwise lock a real trader out after a handful of orders. It fails open: if Redis is unavailable the throttle is skipped rather than halting all trading, because the money-safety guard here is the per-order balance check, not the rate limit.

Four types, two engines

The body's type is accepted in four values, and they split immediately.

type Where it goes Rests in
limit The matching engine trading.orders + trading.open_orders_by_market
market The matching engine Fills or is rejected; never rests long
stop_limit The stop-order path trading.stop_orders
stop_market The stop-order path trading.stop_orders

Anything else is rejected with 422 before a token is touched.

The split is deliberate and structural. The matching engine has no trigger mechanism, and its match loop only branches on LIMIT and MARKET — an unrecognised type used to fall into a branch where neither side of the loop could advance, spinning the engine at 100% CPU. Stop orders are therefore diverted into a separate Scylla table the matcher never reads, which is the subject of Stop orders. Nothing on the rest of this page applies to them until they trigger.

Both branches are also refused when the market's status is off. A disabled market stops accepting new orders on the way in; cancels and refunds stay open, which is the right way round for a delisting.

The three statuses, and the one that does not exist

trading.orders.status only ever holds three values.

Status Written by Means
OPEN createOrder, at placement Resting, funds held
CLOSED The matching cycle, when remaining reaches zero Fully filled
CANCELED cancelOrderByUuid Cancelled; the unfilled part has been refunded

A partially filled order is OPEN. The engine writes exactly five columns on a fill — filled, remaining, status, updatedAt and trades — and status is only moved off OPEN when remaining hits zero. So "OPEN" on the admin orders screen means "resting or partly filled", and the only way to tell is to compare filled against amount.

Two columns that look like they should move on a fill and do not:

  • cost is the placement-time figure and is never rewritten. For a market BUY it is a deliberately conservative worst-case, not what the customer paid.
  • average is never persisted at all. The ledger is inserted without it and no update path sets it, so it reads null on every order. Per-fill prices live in the trades JSON on the order itself, which is the permanent record of what executed.

CANCELED is spelled with one L. The admin orders table offers a Cancelled status filter spelled with two, and that value is passed straight into the CQL WHERE, so filtering on it returns nothing. Filter on OPEN or CLOSED and read the rest.

Price-time priority is the physical row order

open_orders_by_market is clustered (price, "createdAt", id), which is exactly what matchmaking.sortOrders derives priority from, in exactly that order. The database therefore returns rows already in matching order and the engine's per-cycle sort has nothing to do — it scans for a violation in O(n) and only sorts when it finds one. price is safe as a key column because nothing ever updates a placed order's price; a re-price would have to be a cancel and a new order.

Practical consequence: two customers resting at the same price fill oldest first, and that holds across a restart, because the ordering is a property of the table rather than of the engine's memory.

One caveat worth knowing before you read a book dump. Cassandra can only serve the full reverse of a declared clustering order, so reading the bid side ORDER BY price DESC also reverses time within each price level. Everything in the platform re-sorts, so this is invisible in behaviour — but a raw SELECT ... ORDER BY price DESC you run yourself will show bids newest-first inside a level.

The hold: what leaves the balance and when

Placement moves funds from wallet.balance into wallet.inOrder on the same row. Nothing is debited; a hold is a move between two columns of one wallet.

Side and type Held In
SELL (limit or market) amount Base currency
BUY limit cost + fee = amount x price + fee Quote currency
BUY market amount x deepest swept price + fee Quote currency

A market BUY does not hold amount x bestAsk. It walks the real asks, level by level, until amount is covered — but what it then holds is not the cost of that sweep. It holds amount multiplied by the deepest price the sweep touched, quantised at the market's quote precision and rounded up, plus the fee — strictly more than the sweep itself would cost whenever more than one level is crossed. The sweep total is computed on that walk, but it is used as the fee basis and for the cost-limit check, never as the hold. If the book cannot cover the amount, placement is refused with 422 ("Order book has insufficient liquidity...") rather than being partly held. The book it walks is the real one — AI market-maker display levels carry a TTL and no backing order, so sizing a hold from them would under-hold against the actual fill.

The order's stored price for a market BUY is then the deepest price the sweep touched, not the average. That looks wrong on the orders screen and is load-bearing: settlement releases each fill's share of the hold pro-rata by quantity, which only covers the fill when every execution price is at or below the stored one. Holding at the average let cheap top-of-book levels release more than they consumed, and the deeper levels then failed with "Buyer has insufficient locked funds" — leaving the sweep half done and the rest of the hold stranded in inOrder.

The extra hold is not a charge. Every fill refunds its own price improvement to the customer's available balance as it settles, so the net cost is the same. The fee, though, is quoted from the realistic average sweep price rather than the worst-case cap, so a market BUY crossing several levels is not overcharged.

Placement is atomic by construction: if the wallet hold fails the order row is rolled back, and if the enqueue fails the hold is released and the row rolled back, in that order. A crash between the two leaves the recoverable state (funds held, order OPEN and cancellable) rather than funds held with nothing to cancel.

An order carries no maximum-slippage setting and records no realised fill price of its own. Slippage protection on a market BUY is the liquidity check above, and the realised prices are the trades JSON. If you were told to look for maxSlippageBasisPoints or actualFillPrice, they do not exist in this product.

Cancellation

Cancels one order and refunds the unfilled part

The timestamp query parameter is required — it is the order's createdAt, part of the Scylla primary key.

Cancels every open order for the caller, including resting stops

The sequence that matters is the first step. claimOrderForCancel splices the order out of the matching queue under the engine lock and returns the remaining the engine sees. That is what makes the refund fill-consistent: any fill that was in flight has already completed, and no later cycle can touch the order after the claim. Reading remaining from a Scylla snapshot instead races concurrent fills, and the stale, too-large refund was partly minted.

The refund is then (cost + fee) x remaining / amount in quote for a BUY, or remaining in base for a SELL, released with an idempotency key and marked release-only so a retry can never credit funds that were not held.

Order of operations after that is chosen so every failure is recoverable: the funds are released before the row is marked CANCELED, and if the status write fails the claimed order is pushed back into the matching queue. An order left claimed-but-not-cancelled would be OPEN in Scylla, holding funds, resting in the displayed book, and invisible to the engine — permanently stuck, because a claim requires queue presence.

Cancel-all claims every order under one lock acquisition rather than one per order. Interleaving engine-lock and wallet-row locks is what produced multi-second lock convoys and MySQL "Lock wait timeout" storms when several cancel-alls ran at once.

Placing an order from a process that does not hold the matcher

Exactly one process may own the matching lease (ecosystem-matching); see Operations. Placement no longer requires it. The order row and the wallet hold are durable wherever they are written, and the engine's in-memory queue is a cache of the orders table — so a process without the lease persists the order and tells the leaseholder that a symbol changed.

Four moving parts, all in Redis:

Key or mechanism Job
eco:matcher:dirty (SET) Durable "these symbols need a resync". Survives a leaseholder restart, coalesces a thousand orders into one member, 24h TTL
eco:matcher:nudge (pub/sub) The doorbell, so the resync happens in milliseconds rather than on the next drain tick. Best-effort by contract
Full sweep, every 60s The backstop. Re-reads every symbol, so a lost nudge or a failed SADD costs latency and nothing else
eco:matcher:cancels (HASH) Cancellations requested off the leaseholder, keyed by order id so a retry cannot duplicate one. No TTL — a lost cancel is never recovered by anything else

Only a symbol string crosses the process boundary. The leaseholder re-reads that symbol's open orders and ingests what it does not already hold, which makes the whole path idempotent: a nudge delivered twice resyncs twice and the second finds nothing to do.

Cancellation needs its own channel because a resync cannot notice it — the order still looks perfectly open, so there is no row for a re-read to see. And it cannot be served on the follower at all, because the claim under the engine lock is the thing that makes the refund safe.

Two log lines are worth knowing. On the leaseholder, Ingested N order(s) for SYMBOL placed by another process is the only external evidence that cross-process placement is working end to end. Served a cross-process cancel for order <id> is its counterpart for cancels.

When an order rests and nothing fills it

Work down this list; each item is a different fault with a different fix.

The market has no counterparty. The ordinary case. Check the book on /trade?symbol=BTC-USDT&type=spot-eco.

The order is outside the resident window. The engine holds the orders nearest the market, per side, per symbol — 25,000 a side by default. Orders beyond that are safe in the ledger and are loaded as price reaches them. The log line is SYMBOL: resident window is N bid(s) / M ask(s) out of a deeper book. Nothing is wrong; see ScyllaDB schema and engine tuning.

The open-orders index is short of the ledger. If the index is missing a row for an order the ledger says is OPEN, the matcher never sees it and it rests forever on the customer's money. Run pnpm eco:index:check — it exits non-zero on drift and prints exactly what disagrees — then pnpm eco:index:repair and restart. The engine also re-checks one market an hour on rotation and falls back to reading the ledger if it catches a shortfall.

A ghost level. The aggregated orderbook table shows depth with no backing order, so the displayed book lies while nothing can match at that price. The signature in the log is:

Orderbook level missing on cancel: order <id> BTC/USDT ASKS @ 65000 —
status will be CANCELED but no level was decremented (ghost-level suspect)

The engine reconciles the aggregated book against real open orders every five minutes; pnpm rebuild:eco-orderbook is the offline repair.

Funds held with no open order. Usually a resting stop order — those reserve at placement, before any trigger. Otherwise run pnpm reconcile:eco-inorder, which only ever releases and never raises a hold.

The residue failure worth recognising

There is one historical fault that produced a genuinely immortal order, and its signature is distinctive enough to name.

orders.id and orders."userId" are declared UUID in Scylla, so the driver returns Uuid objects, while the ids reaching the cancel path come from HTTP params as strings. object === string is always false, so the cancel marked the row CANCELED in Scylla while silently failing to remove the order from the engine's in-memory queue. That left a copy resident with status: "OPEN" and remaining > 0 forever, which the five-minute reconciler then wrote back into the aggregated book as an immortal ghost level. It broke ecosystem Cancel-All, the Hummingbot batch cancel and the operator kill switch at once.

The ids are coerced to strings at the read boundary now. If you are looking at an older install and see a level that survives every cancel and reappears after each reconciliation, this is the shape of it — and a backend restart clears the in-memory copy, because the queue is rebuilt from the ledger at boot.

Where orders appear in the admin

Admin → Finance → Orders → Ecosystem (/admin/finance/order/ecosystem), gated by access.ecosystem.order for the screen and view.ecosystem.order for the data.

Lists ecosystem orders across markets

The screen also carries a cleanup action for corrupted rows — orders with a valid primary key and null symbol, amount, price or side. Those are an artefact of Scylla's upsert behaviour: an UPDATE against a primary key with no row behind it creates the row with nulls everywhere else. The list endpoint filters them out of the display, so the count in the cleanup dialog is the only place you will see them.

Removes corrupted order rows. Supports a dry run.

Run it with dryRun: true first.