Model liquidation cascades, insurance fund, and ADL¶
A real perpetual venue has a three-step liquidation pipeline:
- Liquidation engine takes over the underwater position at the bankruptcy price and walks the book to close it.
- If the close generates a loss the trader's posted equity cannot cover, the insurance fund absorbs the deficit.
- If the fund is depleted, ADL (auto-deleveraging) activates: profitable opposite-side traders are force-closed at the bankruptcy price, ranked by their PnL ratio.
flox's default backtest treats liquidation as a hard stop — the
position disappears at the maintenance-margin-breach price. For
research that needs to know whether a portfolio survives a 5%
adverse move vs cascades INTO a -8% liquidation, that's a
comforting fiction. LiquidationEngine models the full pipeline.
Configure¶
A worked example (Python):
"""Liquidation cascade demo: insurance fund drains, then ADL fires."""
import flox_py as flox
eng = flox.LiquidationEngine()
eng.add_tier(0.0, 0.005)
eng.set_insurance_fund_capital(1000.0)
eng.set_adl_enabled(True)
eng.set_liquidation_slippage_bps(0.0)
# Underwater long: 10 BTC @ 100, equity 50; at mark 40 → PnL -600, deficit 550.
eng.open_position(account_id=1, symbol=1, quantity=10.0,
entry_price=100.0, equity=50.0)
# Profitable short cohort: small one + large one.
eng.open_position(account_id=2, symbol=1, quantity=-5.0,
entry_price=100.0, equity=100.0)
eng.open_position(account_id=3, symbol=1, quantity=-10.0,
entry_price=100.0, equity=100.0)
# Insurance fund only has 1000 — it absorbs the 550 deficit fully here
# because the cascade is small. To trigger ADL, drain the fund first
# or lower the cap.
out = eng.on_mark(symbol=1, mark_price=40.0)
print("liquidated :", out["liquidated"])
print("adl_closeouts:", out["adl_closed_out"])
print("fund delta :", out["insurance_fund_delta"])
print("fund balance :", eng.insurance_fund_balance())
# Drain the insurance fund explicitly, then re-run to see ADL fire.
eng.set_insurance_fund_capital(0.0)
eng.open_position(account_id=4, symbol=1, quantity=10.0,
entry_price=100.0, equity=50.0)
out2 = eng.on_mark(symbol=1, mark_price=40.0)
print("\nsecond run (fund depleted):")
print("liquidated :", out2["liquidated"])
print("adl_closeouts:", out2["adl_closed_out"])
print("cumulative ADL count:", eng.adl_closeouts_count())
import flox_py as flox
eng = flox.LiquidationEngine()
eng.add_tier(min_notional=0, mm_fraction=0.005)
eng.add_tier(min_notional=250_000, mm_fraction=0.01)
eng.add_tier(min_notional=1_000_000, mm_fraction=0.025)
eng.set_insurance_fund_capital(10_000_000)
eng.set_adl_enabled(True)
# Open a 5 BTC long at 50k, equity 1000 USDT.
eng.open_position(account_id=42, symbol=1, quantity=5.0,
entry_price=50_000.0, equity=1000.0)
# On each tick, feed the new mark price.
outcome = eng.on_mark(symbol=1, mark_price=49_500.0)
if outcome["liquidated"]:
print("liquidated:", outcome["liquidated"])
const eng = __flox_liquidation_engine_create();
__flox_liquidation_engine_add_tier(eng, 0, 0.005);
__flox_liquidation_engine_set_insurance_fund_capital(eng, 10_000_000);
__flox_liquidation_engine_open_position(eng, 42, 1, 5.0, 50000.0, 1000.0);
const n = __flox_liquidation_engine_on_mark(eng, 1, 49500.0);
FloxLiquidationEngineHandle eng = flox_liquidation_engine_create();
flox_liquidation_engine_add_tier(eng, 0.0, 0.005);
flox_liquidation_engine_set_insurance_fund_capital(eng, 10000000.0);
flox_liquidation_engine_open_position(eng, 42, 1, 5.0, 50000.0, 1000.0);
uint32_t n = flox_liquidation_engine_on_mark(eng, 1, 49500.0);
Canned profiles¶
| Profile | Tiers | Insurance fund cap | Slippage |
|---|---|---|---|
binance_um_futures |
6 | 900M USDT | 15 bps |
bybit_linear |
4 | 100M USDT | 20 bps |
okx_swap |
3 | 150M USDT | 20 bps |
Numbers approximate the published values; tune for your engagement.
What the engine does on each tick¶
For every open position on the symbol being marked:
- Compute notional =
|quantity| * mark_priceand resolve the maintenance-margin rate from the tier ladder. - If
equity + unrealized_PnL < notional * mm_fraction, the position is liquidated atmark * (1 - sign(qty) * slippage_bps/10000). - Compute realized loss vs entry; deficit beyond posted equity accumulates.
- Insurance fund pays as much of the total deficit as it can.
- If
adl_enabledand deficit remains, rank profitable opposite-side positions by the configured ADL ranking strategy (default PnL ratio) and force-close from the top until the deficit is absorbed.
ADL ranking strategies¶
Real venues compute the ADL queue with different formulas; pick the one that matches the venue being modeled. Each canned profile sets the strategy its venue actually uses.
| Strategy | Formula | Used by |
|---|---|---|
pnl_ratio |
upnl / equity |
default; OKX preset |
binance |
upnl × leverage (lev = notional/equity) |
Binance UM preset |
bybit |
same as binance (alias) |
Bybit linear preset |
position_size |
|quantity| |
small DEX perps |
Higher score = closer to the front of the ADL queue. Set via
set_adl_ranking(...) with a string name or the AdlRanking enum:
Cumulative counters track liquidations / insurance payments / ADL closeouts across the engine's lifetime.
Cascade modelling¶
To reproduce a flash-crash cascade:
- Open a portfolio of leveraged positions at various entry prices and equity levels.
- Step the mark price down (or up) in small increments, calling
on_markeach step. - Watch the cumulative counters: each tick that liquidates positions feeds back into the next as the insurance fund drains and ADL activates.
Integrate with the order book¶
By default the engine closes underwater positions at a flat-bps slippage from the mark — fine for portfolio-level research, but the close price ignores the actual book depth.
Attach a SimulatedExecutor to route liquidation orders through
the matching engine. The liquidation becomes a real market order
that consumes book liquidity, pays venue fees, and samples
configured latency:
With an executor attached, the close price reflects the
post-walk-the-book average instead of a flat-bps haircut. When
the book is too thin to fill the entire position in one tick, the
remainder stays on the engine's books for the next on_mark tick
to retry.
Detach by passing None / null; engine falls back to flat-bps
behaviour.
Mark-impact feedback and intra-tick cascades¶
When liquidation orders walk a thin book, the realized close-price
drifts away from the tape mark. On real venues that drift feeds
back into the mark-price formula and can underwater positions that
were healthy at the tape input. The engine models this via
set_mark_impact_model:
| Model | Formula | Notes |
|---|---|---|
none |
mark = tape input | Default. No feedback, T036 behaviour. |
book_anchored |
mark = (1 − w) · tape + w · book_mid | Matches Binance's index/mark blend. |
book_only |
mark = book_mid | Worst-case cascade test; falls back to tape when the book mid is unavailable. |
After each liquidation round the engine queries the executor's
post-fill book_mid_price(symbol), recomputes the mark per the
selected model, and re-runs maintenance-margin checks. If new
positions go underwater, the engine cascades within the same
on_mark call up to max_cascade_depth rounds (default 5). Set
the depth to 0 to disable cascading; without an executor the
model is a no-op (no book mid available).
The per-round count is recorded in cascade_sizes_per_tick, so
distribution-level reports can separate first-order from
second-order liquidations.
Cross-margin accounts¶
For account-level cross-margin (the real-world default on most
prop accounts), attach a flox.Account so the
engine walks aggregate equity + uPnL across all symbols and closes
the worst-PnL leg first when underwater.
When a cross-margin account is underwater past what its own equity can absorb, the engine routes the residual deficit through the same two-step fallback used for orphan positions:
- Insurance fund covers what it can.
- If
set_adl_enabled(True)and a residual remains, ADL scans every profitable opposite-side position across every attached account plus the orphan position book, ranks them by the configuredAdlRanking, and force-closes from the top until the deficit is absorbed. ADL-closed account positions credit their owning account's equity (cross mode) or the leg's isolated_equity slice (isolated mode) with the realised PnL.
See Cross-margin accounts for the full guide.
Notes¶
- The engine is per-venue (per-margin-pool). For cross-margin or multi-asset accounts, instantiate one engine per pool.
- The slippage knob is a flat bps haircut on the bankruptcy
price; for venue-specific book-walk simulation, layer the
existing
SlippageProfileon top. - ADL ranking is configurable per-engine; see the ranking table above. Custom ranking via a user-supplied callback is not in scope.