A Python simulation of the Auto-Deleveraging (ADL) mechanism in perpetual futures markets, calibrated to the May 19, 2021 BTC/USDT crash. It runs a full liquidation cascade over a heterogeneous trader population and compares three ADL policies — Binance's production queue, a proportional-equity baseline, and the minimax-leverage water-filling optimum of:
Campbell, Hey, Moallemi & Nutz, Risk-Based Auto-Deleveraging, arXiv:2603.15963 (Columbia University, 2026)
The aggregate haircut is identical across all three policies — the same shortfall has to be covered. The whole story is distributional: which traders absorb it.
| Metric | Binance | Proportional | Water-filling |
|---|---|---|---|
| Total liquidations | 244 | 244 | 244 |
| Insurance fund depleted at tick | 240 | 240 | 240 |
| ADL events | 3 | 3 | 3 |
| Total ADL haircut | $1,103,113 | $1,103,113 | $1,103,113 |
| Traders fully wiped by ADL | 81 | 0 | 57 |
| Max single-trader haircut | $71,808 | $38,368 | $50,327 |
| Gini coefficient of haircuts | 0.602 | 0.517 | 0.566 |
Same total, three different distributions. Binance concentrates the haircut on the top of its PnL% × leverage queue and wipes 81 winners; the proportional rule equalises the fractional pain and wipes none; water-filling shaves the highest-leverage accounts hardest and lands in between — matching the risk-based intent of the paper.
python robustness.py shows this ordering (Binance ≥ water-filling ≥ proportional, by Gini) holds across seeds and insurance-fund sizes, not just this one run.
Reproducibility.
python main.pyuses the deterministic synthetic path by default, which is what the table above is computed on. The synthetic path contains the deep gap-down that actually depletes the insurance fund. Real Binance klines for the 00:00–12:00 UTC window are comparatively mild (no single-minute close move > ~3 %) and typically do not trigger ADL — fetch them with--source liveto sanity-check the mechanics.
- Loads a price path (
--source syntheticby default;livepulls 1-minute BTC/USDT perpetual klines from Binance Futures,autotries live then falls back). - Simulates a population of 350 traders (250 long, 100 short) with log-normal leverage and position-size distributions, entering around $43,200.
- Runs the full exchange cascade on each tick: mark-to-market → liquidation check → fill with dynamic slippage → insurance-fund accounting → ADL dispatch.
- Compares three ADL policies on the same population:
- Binance — sequential queue ranked by
PnL% × effective leverage. - Proportional-equity — every opposing position contributes the same fraction
αof its equity (the minimax fractional-haircut / egalitarian baseline). - Water-filling — the paper's minimax-leverage optimum: shave the account-leverage distribution from the top down to a threshold
λ̄, found by a 1-D root-find so the haircut exactly covers the shortfall.
- Binance — sequential queue ranked by
- Outputs three dashboards, a policy-comparison figure, a robustness sweep, and a per-trader CSV report.
pip install -r requirements.txt
python main.py # default: synthetic scenario → reproduces the table above
python main.py --source live # real Binance klines (mild window; usually no ADL)
python robustness.py # seed / insurance-fund sensitivity sweep
pytest -q # run the test suiteCLI flags: --source {synthetic,live,auto}, --n-longs, --n-shorts, --seed, --out.
Outputs are written to out/:
| File | Description |
|---|---|
adl_dashboard_binance.png |
6-panel simulation dashboard — Binance policy |
adl_dashboard_proportional.png |
6-panel simulation dashboard — proportional policy |
adl_dashboard_waterfill.png |
6-panel simulation dashboard — water-filling policy |
adl_policy_comparison.png |
Lorenz curves, haircut histograms, outcome breakdown |
robustness_sweep.png |
Ordering across seeds and insurance-fund sizes |
trader_report.csv |
Per-trader status, haircut and PnL under all three |
.
├── data_fetcher.py # load_prices(): Binance fetch + synthetic flash-crash generator
├── models.py # Position, TraderAccount, InsuranceFund
├── trader_factory.py # Generates the heterogeneous trader population + insurance fund
├── adl_engine.py # ADLPolicy.{BINANCE, PROPORTIONAL, WATERFILL}
├── exchange.py # Per-tick liquidation cascade + dynamic slippage
├── visualization.py # Per-policy dashboards + N-way comparison figure
├── main.py # Entry point — runs all policies, saves outputs
├── robustness.py # Seed / insurance-fund sensitivity sweep
├── explorer.ipynb # Short EDA notebook over trader_report.csv (needs seaborn)
├── tests/test_adl.py # pytest unit + integration tests
└── small_paper.pdf # Accompanying write-up
During normal ticks slippage is a flat 30 bps. During gap events (single-tick moves > 3%) it scales with the tick return, reaching ~4.8% at the May-19 gap ticks — placing fills below bankruptcy prices and triggering insurance-fund shortfalls.
Positions are ranked descending and deleveraged sequentially until the residual shortfall is covered.
Every opposing position loses the same fraction of equity — the minimax fractional-haircut allocation. No trader is fully wiped unless total opposing equity is insufficient.
The paper proves the ADL policy that minimises expected loss under a risk objective is minimax-leverage: reduce every position with leverage above a threshold λ̄ down toward λ̄, leaving lower-leverage positions untouched. This simulation implements it directly. For a threshold, position j with account leverage λ_j and maximum clawable profit P_j contributes
and the total haircut λ̄. The "water level" λ̄ that just covers the shortfall scipy.optimize.brentq) — the one-dimensional root-find the earlier version left as an extension.
This is a controlled comparison of ADL allocation rules, not a market-microstructure engine. The choices below are deliberate; the point is to isolate the distributional effect of the policy, holding everything else fixed.
-
The synthetic scenario is calibrated to make ADL fire. The default price path is a hand-shaped approximation of May-19-2021, and the insurance fund is seeded at a deliberately thin 0.25 % of notional so the gap-down actually breaches it. These are demonstration parameters, not empirical estimates — the headline numbers show how the policies differ under stress, not how often such stress occurs.
robustness.pysweeps both the seed and the fund size to show the ordering is stable even though the magnitudes are not. -
"Leverage" in the water-filling rule is nominal account leverage. The paper's minimax-leverage optimum is implemented by shaving the nominal-leverage distribution from the top. Effective (mark-to-market) leverage would seem more natural but is invariant under a proportional position close — closing a fraction
fscales notional and equity together — so it cannot be water-filled by size reduction. Nominal leverage is the fixed, per-account risk attribute the rule acts on. This is a faithful adaptation of §4 to profit-clawback ADL mechanics, not a line-by-line reimplementation of the paper's continuous-time model. - Single product, close-price ticks. One BTC perp, 1-minute close prices — intrabar highs/lows (and the deeper intraday wick of the real crash) are not simulated, so liquidation timing is conservative.
- Stylized fills and fees. Liquidations fill at mark ± dynamic slippage; funding payments, maker/taker rebates on the ADL leg, tiered maintenance margin, and partial-liquidation ladders are omitted. The insurance fund is a single scalar buffer.
-
Static population. Traders open once at
$t=0$ and never add margin, hedge, or re-enter; there is no order book, so slippage is a reduced-form function of the tick return rather than an emergent quantity.
None of these change the central result — the aggregate haircut is fixed by the shortfall, so the policies can only differ in who pays — but they do bound what the absolute figures mean.
- Different asset / crash window — edit
CRASH_START_MS/CRASH_END_MSindata_fetcher.py. - Larger population —
python main.py --n-longs 5000 --n-shorts 2000(runs in well under a second). - Different leverage distribution — adjust
LONG_LEV_MU,LONG_LEV_SIGMAintrader_factory.py. - Insurance-fund size —
build_traders(..., insurance_frac=0.01); at ≥ ~10 % of notional ADL never fires.robustness.pysweeps this automatically. - Add a fourth policy — add an
ADLPolicyvalue and an_execute_*method inadl_engine.py;main.pyand the comparison figure pick it up generically.
@article{campbell2026adl,
author = {Campbell, Steven and Hey, Natascha and Moallemi, Ciamac C. and Nutz, Marcel},
title = {Risk-Based Auto-Deleveraging},
year = {2026},
journal = {Working Paper},
note = {arXiv:2603.15963, Columbia University}
}MIT — see LICENSE.