Skip to content

feat(execution): Liquidate the simulated venue's spot margin account at a configured margin level - #812

Merged
Niqnil merged 6 commits into
developfrom
feat/803-sim-margin-liquidation
Nov 1, 2026
Merged

Niqnil merged 6 commits into
developfrom
feat/803-sim-margin-liquidation

Conversation

@Niqnil

@Niqnil Niqnil commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Closes #803

What

The simulated venue can now liquidate its spot margin account. It is off by default, and a
configuration without it deserialises and behaves exactly as before.

SpotMarginConfig::with_liquidation(SpotMarginLiquidation::new(level, fee_rate)?) sets:

  • a liquidation level: a margin level (what the account holds over what it owes, valued in
    the valuation asset), at least 1;
  • a fee rate in [0, 1), charged on the value of the debt the liquidation repays.

SpotMarginLiquidation::with_margin_call(level) also adds a margin-call level above the
liquidation level.

When

After every input (advance_time and apply_market), last, the venue values the account the
same way the leverage limit does. The mid price is used, else the last price, of a spot pair with
the valuation asset. A margin call or liquidation is checked for at that point.

  • At or below the liquidation level, the account is liquidated.
  • With a margin-call level, NoticeKind::MarginCall is sent when the account falls to it and
    NoticeKind::MarginRestored when it rises back above it. Each is sent once per crossing. A
    liquidation ends a margin call without a MarginRestored.
  • A debt the venue cannot value is not checked, with one warn! per venue. Collateral it cannot
    value counts as zero.

How a liquidation runs

  1. An AccountNotice of NoticeKind::Liquidation, carrying the margin level.
  2. Every open order is cancelled, resting or held, releasing what each held.
  3. Each debt is repaid from that asset's own free balance.
  4. The account's other assets are sold for what it owes of the valuation asset. They are sold in
    name order, and only as much as needed.
  5. Each other asset owed is bought back with the valuation asset. If the buy costs more than the
    free valuation balance, other assets are sold first to cover the difference.
  6. The fee is charged in the valuation asset, as a new CashFlowKind::LiquidationFee { rate, repaid }.
    If the free balance is short, more assets are sold first. A fee that still cannot be paid takes
    free below zero, with a warn!.

Each forced trade fills as a market order on arrival would, through the venue's fill model and
slippage, as a taker, and against the depth on offer. It is a Trade with
TradeOrigin::Liquidation, StrategyId::unknown(), no client order id and no order snapshot,
and it repays the debt of the asset it delivers.

A debt that one pass cannot close (thin depth, no market, too little left to sell) is left owed,
with one warn! per liquidation. The liquidation continues on every later input, without another
notice, until the account owes nothing.

While a liquidation is under way the venue lends nothing. An order that would need a loan is
rejected as unaffordable, its reason showing nothing borrowable, and a held order whose fill would
need one is cancelled. An order the account can pay for from its own balance is accepted, and
cancelled on the next input if it is still open. A debt the venue cannot trade out of keeps
lending off until the account repays it.

Configuration checks

with_liquidation returns a SpotMarginConfigError:

  • LiquidationLevelBelowOne;
  • LiquidationFeeOutOfRange;
  • MarginCallNotAboveLiquidation;
  • LiquidationWithinLeverage, for a level of L / (L − 1) or more for a maximum leverage L.
    An account borrowed to the limit is at that margin level, so the venue would liquidate an
    account for borrowing what it was allowed to.

CFD positions are not liquidated. The docs say so where CFD accounting is described.

Choices worth a look

  • The fee is charged per pass, on what that pass repaid. A liquidation that closes in one pass
    is charged once. Each pass's fee comes out of what the account holds after that pass's
    repayments, selling for it as for the debt, as a venue takes its fee out of the proceeds of the
    liquidation's trades. So a debt a pass leaves owed has that much less to be repaid from.
  • The debt repaid is measured after the cancels. A loan that a cancelled order gives back
    under auto-repay-at-cancel is not charged the fee.
  • Collateral is sold for exactly what is needed, at the leg's price. Each division rounds, so
    the quantity is nudged by its last digit until the trade delivers all of what is needed without
    costing more than the account holds. If a quantity costs too much, the cap is lowered, so the
    nudge cannot climb back to it.
  • A leg draws one slippage sample once priced. The sample is not handed back if the leg then
    trades nothing (for example, the book has no depth).
  • A leg whose fee takes all it delivers is not traded. The debt is left owed, with the
    shortfall warning.

Also

  • SimulatedVenue::borrowable now shares the account valuation (valued) with the liquidation
    check, so the two cannot drift apart.
  • The TradeOrigin table, the AccountNotice producers list and the CashFlowKind producers list
    now include the simulated venue.

Tests

  • exchange/mock/venue/tests/liquidation.rs, 25 tests:
    • a short bought back, and a long sold down;
    • cancels first, held and resting orders alike, in client order id order;
    • no lending while a liquidation is under way, to a limit order, a market order or a held
      order's fill;
    • a dust debt that values at zero;
    • margin call, restore, and once-per-crossing;
    • thin depth closed over later inputs, with distinct fee ids;
    • a debt too large to cover;
    • an unvalued debt, warned once;
    • a clock-driven liquidation;
    • a debt in a pair's quote;
    • a base-asset fee grossed up;
    • collateral sold in name order;
    • a buy-back that sells collateral first;
    • a fee that is partly covered;
    • the rounding nudge in both directions;
    • the quantity the fill model sees;
    • an unfed pair passed over;
    • a leg whose fee takes everything it delivers.
  • Two configuration tests in margin.rs.
  • rustrade/tests/test_sim_venue_spot_margin.rs: a liquidation fed through the indexer into
    EngineState leaves the position flat, and the engine's balances equal the venue's.

…at a configured margin level

`SpotMarginConfig::with_liquidation(SpotMarginLiquidation)` turns it on: a liquidation level, a fee
rate on the debt repaid, and an optional margin-call level. After every market tick and clock
move, an account at or below the level is sent a `Liquidation` notice, has its open orders
cancelled, and has every debt closed by forced taker trades reported as
`TradeOrigin::Liquidation` fills, selling other assets for the valuation asset as needed. The fee
is reported as the new `CashFlowKind::LiquidationFee`. A debt it cannot close at once stays owed,
and the liquidation continues on later inputs. The margin-call level sends `MarginCall` and
`MarginRestored` once per crossing.

The leverage limit and the liquidation check share one valuation of the account.
… fix the rounding nudge

A forced trade the account's balance limits, whose quantity rounded up to cost more than the
balance, was nudged down a digit and then back up, until its four tries ran out, so it traded
nothing. A quantity that costs too much now lowers the cap, and the largest affordable quantity
tried is traded.

New tests cover selling collateral before a buy-back, a fee the free balance partly covers, the
nudge in both directions, the quantity the fill model is asked to price, a pair without a market,
a leg whose fee takes all it delivers, distinct fee ids, and the warnings that are given once.

`raise` already does nothing for a need that is not positive, so its callers no longer check, and
the lending limit takes the headroom's maximum with zero rather than matching on its sign.
…zero, and pin the cancel order

- A dust debt at a tiny price can value at zero, and the margin level divides by the debt, so the
  check now treats a zero valuation as nothing to liquidate rather than panicking.
- A test cancels a held market order and two resting orders placed out of order, and pins that
  each releases what it held, by client order id.
- `SpotMarginLiquidation` is `Copy`, and `SpotMarginConfig::liquidation` returns it by value.
- The docs say a forced trade draws from the slippage stream once priced, even if it trades
  nothing, and that `apply_market`'s margin check runs once, at the end of the tick.
…way, and document the fee's priority

- While a liquidation that could not close every debt is still under way, the simulated venue
  lends nothing, so an order that would need a loan is rejected as unaffordable, its reason
  showing nothing borrowable. An order the account can pay for from its own balance is still
  accepted, and cancelled by the next input if it is still open. Once nothing is owed, the venue
  lends again.
- The docs and CHANGELOG say that each pass charges its fee before any debt it leaves owed,
  selling for it as for the debt, as a venue takes its fee out of the proceeds of the
  liquidation's trades.
- The dust-debt check's comment says the liquidation state is left as it was.
… orders, and clarify the docs

- The lending-gate test also has a market sale refused for a loan the leverage limit would
  grant, and asserts the self-funded order is reported Cancelled, not just named.
- A new test holds a self-funded market order for the next bar while a liquidation is under way;
  the bar's open makes its fill need a loan, so the order is cancelled and the liquidation then
  closes the remaining debt.
- The docs and CHANGELOG say each pass's fee comes out of what the account holds after that
  pass's repayments, that a held order's fill needing a loan is cancelled, and that a debt the
  venue cannot trade out of keeps lending off until the account repays it.
@Niqnil
Niqnil merged commit 5a774ea into develop Nov 1, 2026
14 checks passed
@Niqnil
Niqnil deleted the feat/803-sim-margin-liquidation branch November 1, 2026 01:23
@Niqnil Niqnil mentioned this pull request Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant