Skip to content

Model liquidation cascades, insurance fund, and ADL

A real perpetual venue has a three-step liquidation pipeline:

  1. Liquidation engine takes over the underwater position at the bankruptcy price and walks the book to close it.
  2. If the close generates a loss the trader's posted equity cannot cover, the insurance fund absorbs the deficit.
  3. 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"])
import { LiquidationEngine } from "flox";

const eng = new LiquidationEngine();
eng.addTier(0, 0.005);
eng.addTier(250_000, 0.01);
eng.setInsuranceFundCapital(10_000_000);
eng.setAdlEnabled(true);

eng.openPosition(42, 1, 5.0, 50_000.0, 1000.0);
const liquidated = eng.onMark(1, 49_500.0);
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);
from flox.backtest import LiquidationEngine

eng = LiquidationEngine()
eng.add_tier(0.0, 0.005)
eng.set_insurance_fund_capital(10_000_000.0)
eng.open_position(42, 1, 5.0, 50000.0, 1000.0)
n = eng.on_mark(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:

  1. Compute notional = |quantity| * mark_price and resolve the maintenance-margin rate from the tier ladder.
  2. If equity + unrealized_PnL < notional * mm_fraction, the position is liquidated at mark * (1 - sign(qty) * slippage_bps/10000).
  3. Compute realized loss vs entry; deficit beyond posted equity accumulates.
  4. Insurance fund pays as much of the total deficit as it can.
  5. If adl_enabled and 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:

liq.set_adl_ranking("binance")
# or
liq.set_adl_ranking(flox_py.AdlRanking.PositionSize)
liq.setAdlRanking("position_size");
// or numeric: 0=pnl_ratio, 1=binance, 2=bybit, 3=position_size

Cumulative counters track liquidations / insurance payments / ADL closeouts across the engine's lifetime.

Cascade modelling

To reproduce a flash-crash cascade:

  1. Open a portfolio of leveraged positions at various entry prices and equity levels.
  2. Step the mark price down (or up) in small increments, calling on_mark each step.
  3. 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:

exec = flox.SimulatedExecutor()
# ... set up book updates, fees, latency on exec ...
liq = flox.LiquidationEngine.binance_um_futures()
liq.set_executor(exec)  # detach: pass None
const exec = new SimulatedExecutor();
const liq = new LiquidationEngine();
liq.loadProfile("binance_um_futures");
liq.setExecutor(exec);

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:

liq.set_executor(exec)
liq.set_mark_impact_model("book_anchored", weight=0.3)
liq.set_max_cascade_depth(5)
liq.setExecutor(exec);
liq.setMarkImpactModel("book_anchored", 0.3);
liq.setMaxCascadeDepth(5);
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.

acct = flox.Account(account_id=42, equity=10_000.0)
acct.open_position(1, 5.0, 50_000.0)
acct.open_position(2, -30.0, 3_000.0)

liq = flox.LiquidationEngine.binance_um_futures()
liq.attach_account(acct)
const acct = new flox.Account(42, 10_000);
acct.openPosition(1, 5.0, 50_000);
acct.openPosition(2, -30.0, 3_000);
liq.attachAccount(acct);

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:

  1. Insurance fund covers what it can.
  2. 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 configured AdlRanking, 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 SlippageProfile on top.
  • ADL ranking is configurable per-engine; see the ranking table above. Custom ranking via a user-supplied callback is not in scope.