When margin
goes negative,
something
must act. — Liquidation engine, v1

Pure functional core driving a single‑actor imperative shell. Detects MMA breach, cancels open orders, places IOC liquidations at a protective limit, parks accounts it cannot unwind. Dark by default behind a global kill‑switch and a per‑user gate. Every suppressed trigger is itself an audit row — the action is suppressed, the breach is observed. FTX/Alameda invariant (no per‑account bypass in engine code paths) enforced by proptest. Nine PRs, four weeks, dark until #9.

Linear
A‑3273
Author
Loc Nguyen
Status
Draft · v1 · scope frozen
Crate
rs/liquidation‑engine
Pattern
functional core / imperative shell (mirrors rs/rome PR #1843)
Schedule
W1 schemas+core · W2 shell · W3 tests+GUI · W4 DryRun→soak→Live
Default at deploy
kill_switch = Off · gate = FALSE
01

Kill switch

Source of truth lives in Redis at liquidation_engine:kill_switch. The shell re‑reads the key at every dispatch (belt‑and‑braces). Off ships at first deploy. The engine is dark, harmless, and observable for one full soak week before a deliberate config flip turns it on.

Engine mode OFF

Engine is dark. A trigger from risk‑monitor opens an incident, persists, audits, and posts to Slack — but no order ever leaves the box. This is the only mode an institutional account is ever in unless explicitly opted in.

02

The blast radius map

Click any node. Risk‑monitor is the canonical breach detector; the engine never recomputes MM. Redis pubsub wakes the engine; margin_status validates the breach before any incident opens. Dashed red edges are best‑effort transports guarded by Postgres authority.

wakeup (best‑effort) margin_status (authority) instrument:*:mark POST /admin/liquidate PlaceOrder fills (dropcopy) live state audit log incident threads risk‑monitor MMA edge detector risk‑engine v1 mark publisher admin GUI / ops manual trigger liquidation‑engine driver + pure core state_machine.rs single mutator ▸ mark_cache (RwLock) order‑gateway cancel_all + place IOC postgres liquidation_account_state clickhouse liquidation_log (replay) slack incident threads
▷ Tap a node

Liquidation Engine

single mutator · pure core + shell

The driver is the only task that touches live EngineState. Everything else — Redis wakeup listener, PG validator, OG client, persistence writers, Slack — pushes validated events into one bounded mpsc. handle_event(state, event) → (state, Vec<Effect>) is pure, replayable, and proptestable. Redis wakes; Postgres validates; the driver dispatches returned effects in deliberate order: persist → audit → cancel → place → notify.

Core: pure, no I/O Shell: kill‑switch double‑check Bounded mpsc — Tick is the only droppable event
03

Drive the account

Click events. Watch state transition, effects emit, and the per‑instrument attempts counter climb. The pure core makes invalid states unrepresentable: "Liquidating with no incident" cannot be expressed in the type system. Five consecutive rejects on the same instrument enters a pre‑park probe; fresh healthy MMA resolves instead of paging ops.

IDLE
no row in PG
LIQUIDATING
incident open
PARK GUARD
wait one tick
PARKED
ops or self‑heal
RESOLVED
MMA ≥ 0

Event / effect log

Static lifecycle reference event × effect · §5.0 of the design doc

The playground above is the live thing. This is the contract behind it: every event the pure core accepts, every effect the driver dispatches, in the order §5.2 prescribes. next = select_next_action(state) — emitted only when an eligible instrument exists, the kill-switch is Live, the mark is fresh, margin_status validated the breach, and auto_liquidation_enabled was true at trigger ingress.

Event PersistAccountpostgres RemovePersistpostgres CancelAllOrdersorder-gateway PlaceLiquidationOrderorder-gateway AppendAuditLogclickhouse EmitSlackslack
TriggerCandidateValidated StaleMarginStatus StaleTriggerDropped
TriggerReceived validated MarginBreach Liquidating next TriggerReceived BreachDetected
TriggerReceived AdminManual Liquidating next TriggerReceived BreachDetected
TriggerReceived duplicate · §6.5 DuplicateTriggerDropped
TriggerReceived auto_liq=false · §6.2 Parked AutoLiquidationDisabled AccountParked AccountParked
OrdersCancelled Liquidating next OrdersCancelled
OrderAcked Liquidating inflight[i].exchange_id OrderAcked
OrderFilled Liquidating attempts[i]=0, drop inflight[i] next OrderFilled
OrderRejected attempts < N Liquidating attempts[i]++ next OrderRejected
OrderRejected attempts ≥ N PreParkProbe until now+safety_tick PreParkProbeStarted
Tick probe timeout / MMA < 0 Parked AttemptsExhausted AccountParked AccountParked
RiskSnapshotUpdated MMA ≥ 0 · liquidating/probe/recoverable parked Resolved next tick AccountResolved Resolved
RiskSnapshotUpdated freshness=Stale · §6.3 StaleMark StaleMark
RiskSnapshotUpdated freshness=SkewViolation · §6.3 ClockSkewViolation StaleMark
Tick next
KillSwitchChanged Off KillSwitchChanged EngineHalted
KillSwitchChanged DryRun | Live KillSwitchChanged
ConfigUpdated ConfigUpdated
Inflight order sub-machine per (user, instrument) · attempts counter scope

The account FSM above hides a second lifecycle: each outstanding liquidation order has its own short life. Per §10.8, the attempts counter is scoped per (user, instrument) — a stuck market parks only itself, never poisons sibling instruments. A Filled exit resets that instrument's counter to zero; a Rejected exit increments it and, on the fifth consecutive miss, starts the pre‑park probe.

PENDING
POST sent · awaiting ack
ACKED
OG stamped L-id · IOC live
FILLED
dropcopy · attempts → 0
REJECTED
attempts[i]++ · probe at N
Enter PlaceLiquidationOrder is the only entry. The driver pre-generates client_order_id, the shell POSTs og.admin_place_liquidation_order, and the order goes into inflight[instrument].
Ack OrderAcked upgrades the inflight entry with the OG-stamped L-id exchange order id. State stays in Acked until the dropcopy stream reports terminal outcome.
Fill OrderFilled ⇒ drop inflight[i], set attempts[i] = 0. The account stays Liquidating; select_next_action picks the next (user, instrument) by largest MMR.
Reject OrderRejected ⇒ drop inflight[i], attempts[i]++. Hard rejects (no liquidity, halted) arrive once; transient errors (network, 5xx) arrive only after og_client exhausts 100ms / 500ms / 2s backoff.
Park When attempts[i] ≥ max_attempts_before_park the account FSM enters PreParkProbe for one safety tick. Fresh MMA ≥ 0 resolves; otherwise it parks with AttemptsExhausted. That park reason can still self‑resolve on a later healthy snapshot.
04

Who gets liquidated first

Multiple accounts can breach at the same minute. The pure select_next_action(state) → Option<Effect> picks the (user, instrument) with the largest current MMR across every Liquidating account. Invoked on every Tick, OrderFilled, and OrderRejected. ▷ Click Fill or Reject to drive the queue. Watch the next pick re‑sort live.

Alternatives considered (§10.3)
  • rejected per‑account round‑robin · fair, but ignores RFP guidance to bias toward largest MM first
  • rejected pure global priority queue · head‑of‑line risk — one stuck account starves others
  • chosen per‑account worker + global priority selection · head‑of‑line isolation and global fairness. A parked (user, instrument) is skipped; siblings keep winding down.
05

The mark beneath the limit

Every protective limit is mark ± liquidation_limit_bps. The shell reads mark_cache at limit‑build time — a per‑instrument RwLock<HashMap> hydrated at boot via SCAN MATCH instrument:*:mark, then kept warm by Redis keyspace notifications. Same key risk‑engine v1 uses to compute MMA, so breach detection and limit pricing share a basis. Freshness is local recv‑time (the key carries no timestamp). ▷ Press play, then kill the publisher. Watch the held instrument go stale, watch the engine skip‑and‑retry, watch the streak escalate to Park{StaleMark}.

Q1 — source: A · reuse instrument:*:mark Q2 — stale action: D · skip‑retry then park Q3 — boot: A · SCAN‑then‑subscribe Q4 — freshness basis: A · local recv‑time Q5 — escalation: D · config (default 30)
stale_mark_streak[BTC‑USD]

Consecutive ticks the shell observed a stale or missing mark for the position the engine is trying to liquidate. Resets on first successful OrderAcked (§5.4). At mark_stale_park_after, instrument parks per §5.4.

0 / 30
EngineConfig
mark_freshness_max — shell refuses to act if older 3s
1–10s
mark_stale_park_after — consecutive stale ticks → Park{StaleMark} 30
5–60
safety_tick 1s
mark_cache_boot_timeout 5s
Idle. Press ▶ Run clock to start ageing marks. The instrument with the pink stripe is the one the engine is actively liquidating — that is the row whose mark gates PlaceLiquidationOrder. Other rows still age (publisher health signal), but their freshness only matters when they become the next target.
06

Where it can bite

The single chokepoint inventory. Every field in §4 has exactly one enforcement site. Click any card to reveal what the failure mode looks like in prod. These are the surfaces auditors and on‑call should both be staring at.

Catastrophic — wrong outcome Degraded — wrong latency / wrong fairness Operational — needs paging
07

The flag that can't become Alameda

auto_liquidation_enabled is the only per‑user exemption in v1. The FTX failure mode was not that a flag existed — it was that the flag was invisible, unaudited, flippable by one engineer, and suppressed the trigger itself. Three primitives close that gap: (1) the suppression is its own audit event (TriggerSuppressedByGate); (2) every flip writes an immutable row to liquidation_gate_changes with actor + timestamp; (3) the architectural invariant in §6.8 forbids any code path keyed on a specific account. ▷ Flip the gate · ▷ fire a trigger. Watch the suppression land as a row, not as silence.

User record · postgres users
user_id usr_demo
auto_liquidation_enabled click to flip — every flip writes liquidation_gate_changes
§6.8 invariant · enforced by PR #2 proptest prop_assert_eq!( handle_event(state, evt_with_user("usr_x")), handle_event(state, evt_with_user("usr_y")), );

Two synthetic user ids → identical trace. Any code path that special‑cases an account fails CI. The exemption is a snapshotted boolean read at ingress; the engine never looks up an id again.

append‑only audit · clickhouse liquidation_log

change history · postgres liquidation_gate_changes

Written in the same transaction as the users UPDATE. PG is system of record; the row is immutable; quarterly export answers compliance without discovery. Reason‑code enum, dual‑control, client‑facing exemption surface deferred → see §09.

08

Where the industry agrees

Eleven venues read end‑to‑end — Binance, OKX, Bybit, Bitget, BitMEX, Deribit, Hyperliquid, dYdX, Paradex, Drift, GMX/Synthetix, plus the Lighter Oct‑2025 post‑mortem and the FTX/Alameda evidence. Where five‑plus venues converge, AX copies. Where venues diverge or fail, AX picks the simplest defensible choice and writes the failure mode into the doc. Three blast‑radius reductions hide here: (a) JELLY (Hyperliquid, Mar 2025) → notional cap as a prerequisite, not a feature; (b) BitMEX (Mar 2020) → cadence throttle + bounded inflight; (c) FTX/Alameda (Nov 2022) → §6.8 + §6.9. Citations and full memo: docs/plans/liquidation-landscape-research.md.

Topic
Industry convergence
AX v1
Failure mode cited
Mark‑price MMR trigger
Real‑time multi‑source index, EMA‑bounded. Universal.
Match · MMA edge in risk‑monitor, mark from risk‑engine v1
Cancel working orders first
All CEX. All major DEX.
Match · cancel_all_orders(user) before any IOC
IOC at protective limit
Forced IOC at mark ± k·bps / bankruptcy price. Deribit, OKX, Binance, HL.
Match · mark ± liquidation_limit_bps (default 50)
Partial liquidation
Deribit 12.5%/1s · OKX tier‑hop · HL 20%/30s · Binance sized‑IOC.
Match · static per‑instrument liquidation_increment_qty + 1s safety_tick
BitMEX 2020 — engine pushed normal increments into a crashed book
Per‑account selection
Every venue silent. Industry doesn't publish.
Largest current MMR (defensible default — §10.3 alternatives)
Maintenance margin granularity
Tiered MM ladder. 8–12 tiers. Or sqrt‑MMF (Backpack).
Flat MM% + upstream notional cap on retail cohort (substitutes tiered). §10.11
JELLY (HL Mar 2025) — flat MM + no cap on illiquid asset → oracle drain
Liquidation econ model
Splits 5 ways: internal engine, open‑keeper, vault‑monopoly (HLP/JLP), position‑transfer (Drift), keeper‑bounty (GMX/Synthetix).
Internal engine. No keeper market. No vault counterparty.
Insurance fund
Universal CEX. Required for ADL waterfall.
Deferred v2 · treasury backstops bounded retail cohort. §11
Bybit/OKX/Bitget converge on IF drawdown ~30%/8h → ADL trigger
ADL · socialized loss · pool‑absorbs
CEX → ADL (Binance/Bybit). Paradex → socialized. HLP/JLP → pool. arxiv 2512.01112 proves no rule satisfies all 3 axioms.
Deferred v2 · Park{state} + ops manual‑close is the fallback
HL Oct 2025 — first cross‑margin ADL in ~2yr; cross‑margin pro‑rata silent across venues
Book‑depth probing
Mostly static. HL hybrid (20% chunk + 30s cooldown above $100k notional).
Deferred v1.1 · static increment; conservative per‑market config
BitMEX Mar 2020 — engine flooded crashed book
Per‑account exemption mechanism
Never publicly documented anywhere. FTX evidence is the only known wire shape.
Match the post‑mortem. Audited suppression (§6.2 · TriggerSuppressedByGate), change log (§4.2 · liquidation_gate_changes), code‑path invariant (§6.8 · proptest).
FTX/Alameda Nov 2022 — boolean column, no audit, no history, one‑click flip
Throughput throttle
Per‑symbol cadence cap + bounded inflight; Lighter Oct 2025 post‑mortem.
Match · per‑(user, instrument) attempts cap, 1s safety_tick, bounded mpsc
Lighter Oct 2025 — engine throughput collapsed under cascade load
match — copied from convergence differ — defensible choice, alternatives in §10 defer — see §09
09

What v1 refuses to ship

Each deferred item carries a revisit trigger — the observable that flips it from "later" to "now". Not vibes; not roadmap. The non‑goal stays in §1; the alternative (if relevant) sits once in §10; the follow‑up lives in §11 with the trigger attached. No triple‑statement, no drift. ▷ Hover a card for the failure mode being accepted.

v1.1

Tiered MM ladder

Flat MM% + upstream per‑user notional cap on retail cohort. Risk team owns the cap; gates Live flip. Equivalent at retail‑beta scale.
revisit when cap becomes binding for legitimate traders (not exploit‑deterrent); or institutional cohort opts in
accepted risk tier‑hop elegance lost; no economic deterrent above cap (cap is binary)
v2

Insurance fund

Retail cohort bounded by §1.3 notional cap; treasury backstops residuals. Pooled vs per‑contract IF is a v2 design decision.
revisit when treasury‑backstop ceiling approached over rolling window; or institutional cohort enabled
accepted risk bankruptcy fills against treasury, not a ringfenced pool
v2

ADL · socialized loss · pool‑absorbs

Three industry options (Binance‑style ADL, Paradex socialized, HLP/JLP pool). arxiv 2512.01112: no rule satisfies all 3 axioms — Ben‑level decision before v2.
revisit when IF active and drawdown >30% over 8h window; or first uncloseable bankruptcy hits ops manual‑close path
accepted risk ops manual‑close is the only path past Park{state}
v1.1

Book‑depth probing

Static per‑instrument increment. Conservative config (≤5–10% of typical 5bps depth). Hot path to book is sub‑ms RPC territory — not affordable for W2.
revisit when first observed cascade where static increment was larger than 1‑tick depth (BitMEX 2020 shape)
accepted risk may slice into a thin book at full increment for a single tick before retry
v1.1

Gate governance · phase 2

v1 ships: TriggerSuppressedByGate audit, liquidation_gate_changes append‑log, §6.8 invariant. v1.1 adds: reason‑code enum, dual‑control on flip, client‑facing exemption surface, quarterly compliance export.
revisit when first institutional opt‑in; or compliance / counsel asks; or any auditor question
accepted risk single‑admin flip on retail‑only cohort; reason field is free‑text until enum lands
v2

RiskSnapshot protocol extension

Engine consumes whatever risk‑monitor publishes. Per‑instrument MM contribution, cross‑margin pro‑rata, multi‑position selection refinements all wait on a richer snapshot shape.
revisit when multi‑position accounts dominate the cohort; or selection heuristic mis‑picks observed in prod
accepted risk "largest MMR" is per‑(user, instrument) and ignores correlation
v2

MLL · max liquidation latency

Hard SLO on breach‑to‑first‑action. Today: bounded by safety_tick + book conditions. v2: explicit deadline + escalation path (operator page, route widening).
revisit when breach_to_first_action_seconds p99 trends above target across rolling window
accepted risk no hard latency SLO; observed via histogram only
v2

Human approval gate · bps widening

After N rejects, optionally widen liquidation_limit_bps with operator approval. Today: park after 5 rejects, ops manual decision.
revisit when parked‑on‑rejects cases dominate ops workload
accepted risk default refusal to widen — preserves protective limit invariant
v2

TWAP · time‑sliced liquidation

Spread a large position across N intervals. Today: cadence throttle is per‑instrument safety_tick; size is one parameter.
revisit when single‑increment fills routinely cross multiple price levels in audit
accepted risk no schedule‑aware slicing; market impact is whatever book gives
ops

Notional cap (prerequisite, not engine)

Owned by risk team. Lives upstream of the engine. Gates the W4 Live flip — without it, JELLY‑shaped exploit is open. Substitutes tiered MM at retail scale.
due end of W3 — confirmation from risk team before flipping kill‑switch to Live
depends on risk team. Treasury backstop posture (§1.3) and named ops on‑call (§1.3) round out the three prerequisites.
ops

Named on‑call for parked accounts

PagerDuty rotation for the first 4 weeks of Live. AccountParked Slack is the first observable; manual unwind is the only path out.
due end of W3
depends on ops manager
ops

Cross‑margin handling

v1 treats every position as if it were isolated. Cross‑collateral pro‑rata math is the hard part everyone hides (HL Oct 2025 was first public cross ADL in ~2yr).
revisit when cross‑margin accounts admitted; or any cross‑position breach observed
accepted risk retail cohort is single‑collateral; the gap is not load‑bearing yet
10

Nine PRs, four weeks, dark until #9

Each PR ≤ ~1k LOC. App functional after every merge. No PR depends on a future PR's behavior. Engine binary ships dark; flipping to Live is a config change, not a code change. Hard cutoff: code‑complete end of W3; soak window is non‑compressible. Bottleneck is PR #4 — strongest engineer + paired review on the mark‑cache / publisher‑liveness co‑gate. ▷ Tap any row for sandbox state at that stage.

W1Schemas · pure core · risk‑monitor publisher#1 · #2 ∥ #3
W2Shell · mark cache + publisher‑liveness co‑gate · bootstrap · admin REST#4
W3Integration tests · metrics · Slack · admin GUI · code‑complete#5 ∥ #6
W4DryRun → soak ≥ 5–7d → Live flip · retail cohort only#7 · #8