Skip to content

Repository files navigation

BTC Flash Crash — ADL Simulation

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.


Results (synthetic May-19-2021 scenario, seed=42)

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.py uses 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 live to sanity-check the mechanics.


What it does

  1. Loads a price path (--source synthetic by default; live pulls 1-minute BTC/USDT perpetual klines from Binance Futures, auto tries live then falls back).
  2. Simulates a population of 350 traders (250 long, 100 short) with log-normal leverage and position-size distributions, entering around $43,200.
  3. Runs the full exchange cascade on each tick: mark-to-market → liquidation check → fill with dynamic slippage → insurance-fund accounting → ADL dispatch.
  4. 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.
  5. Outputs three dashboards, a policy-comparison figure, a robustness sweep, and a per-trader CSV report.

Quickstart

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 suite

CLI 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

Repository structure

.
├── 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

Key mechanics

Liquidation price

$$m^{\text{liq}} = \frac{p_0 q - M_0}{q(1 - \mu)}$$

Bankruptcy price

$$m^{\text{bkpt}} = p_0!\left(1 - \frac{1}{\lambda}\right)$$

Dynamic fill slippage

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.

Binance ADL score

$$s_j = \frac{\text{UPnL}_j}{M_{0,j}} \times \frac{m \cdot q_j}{E_j(m)}$$

Positions are ranked descending and deleveraged sequentially until the residual shortfall is covered.

Proportional-equity policy (egalitarian baseline)

$$\alpha = \min!\left(1,; \frac{R}{\sum_j E_j}\right), \qquad h_j = \alpha \cdot E_j$$

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.

Water-filling policy (Campbell et al. 2026, §4)

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

$$h_j(\bar\lambda) = \max!\left(0,\ 1 - \frac{\bar\lambda}{\lambda_j}\right), P_j,$$

and the total haircut $H(\bar\lambda) = \sum_j h_j(\bar\lambda)$ is continuous and strictly decreasing in λ̄. The "water level" λ̄ that just covers the shortfall $R$ is found by Brent's method (scipy.optimize.brentq) — the one-dimensional root-find the earlier version left as an extension.


Modeling assumptions & limitations

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.py sweeps 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 f scales 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.


Extending the simulation

  • Different asset / crash window — edit CRASH_START_MS / CRASH_END_MS in data_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_SIGMA in trader_factory.py.
  • Insurance-fund size — build_traders(..., insurance_frac=0.01); at ≥ ~10 % of notional ADL never fires. robustness.py sweeps this automatically.
  • Add a fourth policy — add an ADLPolicy value and an _execute_* method in adl_engine.py; main.py and the comparison figure pick it up generically.

Reference

@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}
}

License

MIT — see LICENSE.

About

Auto-Deleveraging Simulation on BTC May 2021 Flash Crash

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages