Orders and positions
How a futures order is validated, priced and matched, when it pays maker or taker, what a fill does to a position, and every way a position can end — close, stop-loss, take-profit or liquidation.
A futures trade on this platform is two objects. The order is the instruction
and lives in Scylla's orders table; it is what rests in the book and what gets
matched. The position is the exposure it created, in the position table,
and it is what carries margin, PnL and a liquidation price.
Understanding which of the two a screen is showing you saves a lot of confused
support tickets — an order that is CLOSED is not a position that is CLOSED.
Placing an order
A trader opens /trade?symbol=BTC-USDT&type=futures, picks a leverage rung, an
amount and (for a limit order) a price, and optionally sets a stop loss and a
take profit. The ticket sends one request to POST /api/futures/order.
The route validates in this order, and stops at the first failure:
-
Authentication and KYC — the caller must be signed in, and must satisfy the
futures_tradingKYC feature if you have gated it. -
The market exists and is enabled — a market whose status is off answers "Trading is disabled for BTC/USDT." Note that only entry is blocked; cancelling an order and closing a position stay open, so switching a market off never traps money.
-
Side and type —
BUYorSELL,LIMITorMARKET. Nothing else. This is strict for a reason: the matcher only ever looks at those four literals, so anything else would be debited and then rest in the book forever, unmatchable. -
Amount — a finite number greater than zero, and inside the market's
limits.amount.min/max. The limits describe the contract, so they apply to both sides. -
Price — required and positive for
LIMIT. ForMARKETthe submitted price is ignored entirely; the order is priced off the book (below). -
Leverage — must be one of the rungs the market publishes.
1000xon a market offering"1,5,20"is rejected with the available list in the message. -
Cost limits — measured against the notional (
amount × price), not against the margin. -
Balance — the FUTURES wallet in the quote currency must hold
margin + fee.
Every one of those rejections comes back with its real status code and a message naming the thing to change. A 500 from this route means something genuinely broke.
How a market order is priced
There is no external price feed. A MARKET order is priced by walking your own
resting book at the moment of placement, and that walk produces three different
numbers that do three different jobs.
| Number | Taken from the sweep | Used for |
|---|---|---|
| Per-unit cap | The deepest level the sweep reaches | Stored as the order's price; the matcher refuses to fill beyond it |
| Reservation price | The dearest level the sweep touches | Sizing the margin hold |
| Average price | Volume-weighted average of the sweep | The fee, and the cost limit check |
If the book has no liquidity on the other side, or not enough of it, the order is rejected with 422 and a message telling the trader to reduce the size or place a limit order instead. It is never partially priced and never filled at an arbitrary price.
The per-unit cap matters more than it looks. The matcher only crosses when
buy.price >= sell.price, so the cap is what stops a market buy sweeping a
resting ask five times deeper than the depth it was priced against.
Maker or taker
The rate is decided by liquidity, not by side. At placement the route looks at the resting book:
- A
MARKETorder always takes. - A
LIMITorder that crosses the best opposing level takes. - A
LIMITorder that rests makes.
The decision is made once and stored on the order as isTaker, along with the
rate applied. Every refund path reads the stored value, so the debit and the
refund can never disagree about what was charged. A resting buy far below the
market is charged the maker rate, as it should be.
Both rates come from the market's metadata (maker and taker, as
percentages). A 0% rate is valid configuration and is honoured; a missing
rate is rejected, because a market with no fee configuration is a mistake, not a
free market.
Matching
The engine matches on price crossing, serialised so two placements landing in the same tick cannot walk the same book snapshot.
The print is the resting side's price. For an ordinary limit pair that is the seller's ask: a buy that crosses gets the seller's price, and the difference between its own limit and the print is released back to it (see Leverage and margin). When the seller is the one taking liquidity — a market sell, or a reduce-only liquidation — the print is the resting bid instead, so a liquidation cannot hand the whole spread to whoever happened to be resting.
Orders that never rest in the book — market orders and liquidations — are
retired rather than cancelled when they do not fully fill. They are flipped to
CLOSED with whatever filled recorded, and the book is left alone, because
subtracting them from depth they never contributed to would silently delete
another trader's resting size.
Cancelling
DELETE /api/futures/order/{id}?timestamp=... cancels a single open order;
DELETE /api/futures/order/all cancels every open order for the caller.
Two things a cancel gets right, and it is worth knowing why:
- The book is decremented by what is still resting, not by the order's original size. Every fill has already taken its own share out of the level.
- The refund is proportional:
(cost + fee) × remaining ÷ amount. Cancelling the remainder of a half-filled order returns half the margin and half the fee, not all of it. The refunded share of the fee is written back out of platform revenue as an offsetting entry, so cancelled orders do not inflate your reported income.
Refunds always land in the quote-currency FUTURES wallet, whichever side the order was.
From fill to position
When two orders match, each side's position is updated.
A new position records the price the trade actually filled at as its entry — never the limit the order carried. A buy limit at 110 that crosses a resting ask at 100 opens at 100.
An existing position on the same symbol and side is added to, and its entry price is re-weighted by the fill:
newEntry = (oldEntry × oldAmount + fillPrice × fillAmount) ÷ (oldAmount + fillAmount)A position on the opposite side is left completely alone. This desk is hedge
mode. Placing a sell while holding a long opens a second, independently margined
short; it does not reduce the long. Traders arriving from a netting exchange will
expect otherwise, and the position list marks every row mode: HEDGE so the UI
can say so.
The life of a position
A position row carries: symbol, side, entry price, amount, leverage, unrealised
PnL, an optional stop-loss and take-profit price, and a status. Status is one of
exactly three values — OPEN, CLOSED, LIQUIDATED. There is no
PARTIALLY_LIQUIDATED.
Every open position is re-marked against its market's live ticker on a two-second sweep, plus a 60-second cron backstop. On each pass, for each position, in this order:
- Stop loss / take profit are checked first. If the same print reaches both — a gap straight through the range — the stop wins, because that is the side the trader asked to be protected from.
- If it survived that, liquidation is evaluated.
A market whose ticker has never printed has a last price of zero, and those positions are skipped rather than marked at zero — otherwise every position on a brand-new market would read as a 100% adverse move and liquidate instantly.
Closing manually
DELETE /api/futures/position/{id} with the currency, pair and side closes an
open position at the live ticker price, falling back to the entry price (so,
zero PnL) when the engine has no price at all.
The payout is isolated margin:
margin = entryPrice × amount ÷ leverage
pnl = (mark − entry) × amount for a long
(entry − mark) × amount for a short
credit = max(0, margin + pnl)There is no branch that debits on close. A loss is already bounded by the posted margin; taking more would reach into money the position never held.
Closing on a stop or a target
Identical arithmetic, run by the engine's sweep rather than by a request, and
keyed for idempotency off the position id so a replay cannot pay twice. The
position goes to CLOSED and the trader is credited max(0, margin + pnl).
Closing writes the status only. The row's amount still shows the size the
position had, which is what makes the admin table readable as history. A position
that was liquidated against the mark, by contrast, has its amount written to
zero. That difference is the quickest way to tell the two endings apart in the
positions table.
Being liquidated
The engine's own ending. It behaves differently enough to deserve its own page — see Liquidation, which is precise about what each variant writes to the row.
Order statuses
| Status | Meaning |
|---|---|
OPEN |
Resting in the book, or partly filled with a remainder still resting |
CLOSED |
Fully filled, cancelled by the user, or retired without ever resting |
Filled quantity, remaining quantity and the average fill price are stored on the
row, so a CLOSED order still tells you exactly how much of it traded.