Liquidation

The two thresholds, the bankruptcy price, why a liquidation is placed as a real reduce-only order, exactly what each outcome writes to the position row, and what happens when the book cannot absorb it.

6 min readUpdated 3 August 2026liquidation, risk, margin, shortfall

Liquidation is the mechanism that keeps your book solvent, and it is the part of this addon most worth understanding line by line. When it works, a losing position is closed while a slice of its margin remains, and the counterparty is paid out of that slice. When it cannot, the platform pays the difference.

The measure: return on margin

The engine does not compare a price against a stored liquidation price. It computes return on margin for every open position on every mark:

returnOnMargin = leverage × (priceMove ÷ entryPrice)

where priceMove is mark − entry for a long and entry − mark for a short. -1.0 means the posted margin is exactly gone.

Two thresholds act on that number:

Threshold Value What happens
Partial -0.7 — 70% of posted margin lost The position is trimmed by 80% of its size
Full -0.9 — 90% of posted margin lost The position is closed

Closing at 90% rather than at 100% is the whole point: liquidating only once the margin is gone leaves nothing to pay the counterparty with.

The published liquidation price the position API returns is derived from the same constant, never re-typed:

liquidationPrice = entry × (1 − 0.9 ÷ leverage)   long
                   entry × (1 + 0.9 ÷ leverage)   short

When it is evaluated

On the engine's two-second mark sweep, against the symbol's live ticker, for every open position on the book — not only for the two traders party to the last fill. A 60-second cron backstop exists for a realm whose engine never booted.

Within one pass, the trader's own stop loss or take profit is checked first. If the same price reaches both a stop and the liquidation level, the stop wins.

A position already mid-liquidation is skipped, and a position that was trimmed is left alone for 60 seconds before it can be trimmed again. That cooldown is not cosmetic: this engine has a flat maintenance requirement, so return on margin is a function of price and leverage only and is completely unchanged by size. Without the cooldown the very next print would trim the same position again, and again, grinding it to nothing at a mark that never reached the liquidation price.

A liquidation is a real order

The engine does not settle a liquidation against a mark price and flip a flag. It places an order.

  1. It computes the bankruptcy price — where the position's entire posted margin is gone:

    bankruptcy = entry × (1 − 1 ÷ leverage)   long
                 entry × (1 + 1 ÷ leverage)   short
  2. It submits a reduce-only LIMIT order on the opposite side, for the amount being liquidated, priced at the bankruptcy price, carrying the position's id. Cost and fee are zero — the margin it is closing is already posted, so the trader is never charged for being liquidated.

  3. The order sweeps everything resting better than bankruptcy. Whoever takes it inherits the exposure with their own margin behind it, so open interest stays balanced and nobody is paid twice for the same contract.

  4. Whatever the book could not take is retired immediately. A liquidation is immediate-or-cancel; it is never left resting in the book.

Pricing it at bankruptcy rather than at the mark is what makes it a real order: it crosses every level between the mark and bankruptcy, and it refuses to trade beyond the point where the trader has nothing left to pay with.

It carries the position id and touches nothing else. Without that, the liquidating sell would find no short to add to and simply open one — which is exactly why liquidations could not be executed as orders before reduce-only existed.

What it does to the position row

This is the part to get exactly right. The position row is never deleted, and the outcome depends on how much of the liquidation the book absorbed.

Outcome amount status entryPrice leverage stopLossPrice / takeProfitPrice unrealizedPnl
Trim, partly or fully absorbed, size survives reduced by the filled quantity stays OPEN unchanged unchanged unchanged unchanged
Trim or full liquidation, book absorbed everything 0 LIQUIDATED unchanged unchanged unchanged unchanged
Full liquidation, residue settled against the mark written to 0 LIQUIDATED unchanged unchanged unchanged unchanged
Trim the book could not absorb at all keeps whatever the book did take stays OPEN unchanged unchanged unchanged unchanged

updatedAt is bumped in every case.

Three consequences follow, and each one has bitten somebody:

  • There is no PARTIALLY_LIQUIDATED status. A trimmed position stays OPEN, because that is the only status the open-position query and the close endpoint recognise. Writing anything else would strand the surviving slice's margin.
  • A reduce-only fill changes the size only. The entry price stays put — the slice that closed has already been settled and paid, and the survivors keep the entry they were opened at. So does the unrealised PnL field and both exit levels.
  • A LIQUIDATED row has amount = 0; a CLOSED row keeps its amount. Closing a position (manually, or on a stop) writes the status alone. That is the fastest way to read the positions table: zero size means the engine ended it.

What the trader is paid

For each absorbed slice, the payout is isolated margin on the slice, at the price it actually traded at, capped at the margin that slice posted:

margin = entry × fill ÷ leverage
pnl    = (fillPrice − entry) × fill      long
credit = min(max(0, margin + pnl), margin)

Being liquidated is never profitable. The cap can only bind when the mark that fired the liquidation disagrees with the price the book actually paid, and in that case the surplus would be money the counterparty's own capped loss can never cover.

For a residue the book could not take, on a full liquidation only, the trader is credited max(0, margin + pnl) at the mark and the position is closed.

Every credit is idempotency-keyed, and every one of them happens in MySQL before the Scylla row is touched. If the Scylla write then fails, the money is already correct, a loud error is logged, and reconcileFuturesPositions replays the status flip within five minutes. That is why you must never "correct" a position row by hand.

When the book cannot absorb it

This is the case that costs you money, so it is reported rather than swallowed.

A full liquidation must end the position, so any residue falls back to mark settlement. The gap between what the counterparty is owed and what was ever posted is the shortfall, and it is logged with its size:

FUTURES_SHORTFALL  The book could not absorb a liquidation; 0.4 of BTC/USDT BUY was
settled against the mark instead. positionId=… userId=… residualAmount=0.4
markPrice=94800 bankruptcyPrice=95000 entryPrice=100000 marginAtRisk=… credited=…
shortfall=812.34 USDT

Nothing absorbs a shortfall. The loser's loss is capped at their margin; the winner's gain is not. Grep your logs for FUTURES_SHORTFALL on a schedule — a run of them means your leverage rungs are writing cheques your order book cannot cash, and the only levers you have are lower maximum leverage, tighter position size limits, and real depth on the book.

A partial trim that the book could not absorb is abandoned, not settled. Settling a trim against a mark would pay both sides for the same exposure: the trimmed side is paid out while the counterparty stays open at full size, to be paid again for the very same move on the very same quantity. Instead the position keeps whatever the book did take, and the next mark tick re-evaluates it. If the price keeps going, the full threshold takes it, and that path does have to settle.

What the trader is told

Two emails are queued by the engine, by template name:

Template name Sent when
LiquidationNotification A full liquidation completed
PartialLiquidationNotification A trim took at least part of the position

Both are looked up by name in System → Notifications → Templates. If no enabled row exists for a name, the send fails with a "template not found" error in the log and the trader hears nothing — check both are present and enabled before you go live.

A third template name, LiquidationWarning, exists in the code with a MARGIN variable, but nothing in the engine currently calls it. Do not promise traders a pre-liquidation warning email; the position list's liquidationPrice and the admin at-risk queue are what you have.

Operator checklist

    • Both liquidation notification templates exist and are enabled
    • FUTURES_SHORTFALL is in whatever you monitor logs with
    • Maximum leverage per market matches the depth that market actually has
    • The mark sweep is running — see Troubleshooting
    • The at-risk panel on the risk console is checked daily, not only during a move