FABRKNT
Step 6. ADL — auto-deleveraging、safety-net cascade の Layer 3
ADL implementation
レッスン 2 / 5·CONTENT35 分60 XP
コース
Step 6. ADL — auto-deleveraging、safety-net cascade の Layer 3
レッスンの役割
CONTENT
順序
2 / 5

レッスン1 — AdlScore / AdlRecord / AdlReport + adl_score

問い

ADL 候補を「どれだけラッキーに勝ったか」で どうランク付け し、「そもそも候補ではない」を どう型で表す か?

原理(最小モデル)

3 つの設計判断がこのレッスンの核だ。

  • score は newtype AdlScore(i64) newtype にするのは、score の意味が ordering であって arithmetic ではないから。PartialOrd + Ord を derive して「比較できる」だけを許し、Add/Mul を derive しないことで「2 つの score を足す/掛ける」という無意味な操作を 禁止 する。Newtype は subtractive — 操作を table から外す。
  • 「候補でない」は Option<AdlScore> で表す。 flat / losing / zero collateral / zero equity の 4 ケースは None。sentinel(AdlScore(0))を返して caller にチェックさせるのではなく、L2 の orchestration が filter_map で不適格を一掃できる。Option は「この入力からは値が出ない」を型レベルで言う方法。
  • score = pnl_pct × leverage、i128 中間値で計算して i64 に saturate。 両 factor は bps(10000 = 100%)。積は bps² になり病的入力で i64 を overflow するので、i128 で計算 → MARGIN_SCALE で renormalize → 最後だけ saturating cast。

具体例

winner A: collateral 100、long 1 @ entry 100、mark 200(pnl = 100)。

pnl_pct_bps  = 100 × 10_000 / 100 = 10_000
leverage_bps = 200 × 10_000 / 200 = 10_000
score        = 10_000 × 10_000 / 10_000 = 10_000

collateral だけを 50 に下げた winner B は leverage が上がり、score = 26_666 になる。同じ PnL でも高 leverage = 高 score = 先に haircut。

失敗例(誤解)

「不適格には AdlScore(0) を返せばいい」は誤り。全 caller が「0 = 不適格」か「0 = 適格だが unlucky」を毎回判定させられる。素の i64 を score に使うのも誤り — score_a + score_b のような無意味な操作が silently compile してしまう。不適格は OptionNone、score 型は newtype。


ここまでで「なぜ newtype + Option + bps² renormalize か」は着地した。ここから先は、その 3 判断を 実際の adl.rs に組み立てる 深掘りに入る。コードは copy-paste で通る完全形。

🛑 予測。 下の adl_score を読む前に、レッスン0 の 4 トレーダー(A/B/C/D)を score 降順に並べよ。pnl_pct × leverage 慣例で。(答えは末尾の答え合わせで検算する。)

ステップで組み立てる

前提

crates/liquidation/ は Liquidation コース(レッスン13)の後の状態。compute.rs / insurance.rs / scanner.rs / types.rs + lib.rs があり 69 テスト pass。本レッスンの diff は 新規ファイル 1 つ(adl.rs)+ lib.rs の 4 行編集 だけ。

Step 1: crates/liquidation/src/adl.rs を新規作成 — module doc + imports

module doc preamble は cargo doc 読者が最初に見る load-bearing な概念(「ADL が orderbook を bypass する理由」)を運ぶ:

//! Auto-deleveraging (ADL) — Layer 3 of the safety-net cascade (ADL参照実装パート).
//!
//! When [`crate::scanner::LiquidationScanner`] finishes a scan with
//! `ScanReport::unfilled_deficit > 0`, the insurance fund couldn't
//! absorb everything. ADL is the last-resort mechanism: rank the
//! profitable counter-positions in the market by a "how much did they
//! win" score, force-close them in descending order, and haircut their
//! unrealized `PnL` until the deficit is absorbed.
//!
//! ### Why ADL bypasses the orderbook
//!
//! If we kept submitting market orders against profitable positions
//! through the matching engine, every order would punch through the
//! bid/ask stack and crash the mark further — which would push more
//! positions underwater. The feedback loop runs away. ADL is designed
//! to **close positions directly in the bookkeeping layer**, never
//! touching the orderbook. The records this module produces carry the
//! [`CloseOrderSpec`] for parity with Liquidation参照実装(計算パート)'s other paths, but the
//! bridge is expected to apply them as account-state mutations rather
//! than CLOB orders.
//!
//! ### How the haircut works
//!
//! Each ADL'd winner had unrealized `PnL` of `P` at the current mark.
//! In a normal close they'd receive `P` in full. With ADL they receive
//! `P - haircut`, where `haircut = min(remaining_deficit, P)`. The
//! system absorbs the `haircut` amount toward the unfilled deficit.
//! Winners with the highest score get the first cut; if the cumulative
//! haircuts reach the deficit before the candidate pool is exhausted,
//! later winners are untouched. If the candidate pool runs out first,
//! `AdlReport::deficit_remaining > 0` and the chain is in genuine
//! unresolved trouble.
//!
//! ### Score
//!
//! Following the Hyperliquid convention, score is
//! `unrealized_pnl_pct × leverage`, expressed in bps²/`MARGIN_SCALE`:
//!
//! ```text
//!   pnl_pct_bps  = pnl × MARGIN_SCALE / collateral
//!   leverage_bps = notional × MARGIN_SCALE / equity
//!   score        = pnl_pct_bps × leverage_bps / MARGIN_SCALE
//! ```
//!
//! The intuition: the "luckiest" winners are those who both made the
//! highest relative gain AND took the most leveraged risk to get
//! there. They take the haircut first. Stable-sort ties break by
//! `AccountId` ascending so two equally-lucky winners produce a
//! deterministic order across validators.
//!
//! ### Determinism
//!
//! - All arithmetic uses i128 intermediates with saturating-to-i64
//!   conversions.
//! - The ranking is a stable sort with a fully-defined tiebreaker.
//! - No clock reads, no `HashMap` iteration.
//!
//! Given the same `(candidates, mark, deficit)`, every validator
//! produces a byte-identical [`AdlReport`].

use crate::compute::{
    account_equity, close_order_spec, notional_value, saturate_i128_to_i64, unrealized_pnl,
};
use crate::types::{AccountSnapshot, CloseOrderSpec, MARGIN_SCALE};
use openhl_clob::AccountId;
use openhl_funding::MarkPrice;

要点: module doc は 実装詳細ではなく cascade position から始めるDeterminism セクションが 3 つの negative(float なし / HashMap iteration なし / clock read なし)を名指し、将来 Utc::now() を呼ぼうとする contributor を思いとどまらせる。close_order_spec を import しているのは L2 の execute_adl が使うから(L1 では unused 警告が出て L2 で消える)。

Step 2: AdlScore を追加

/// ADL ranking score. Higher means earlier force-close.
///
/// Computed as `pnl_pct × leverage`, both expressed in `MARGIN_SCALE`
/// units; the product is renormalized once. Saturates at `i64::MAX`
/// for pathological inputs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AdlScore(pub i64);

要点: PartialOrd + Ord の derive こそ newtype の存在理由(比較が型の目的のときだけ derive する)。Add/Mul/Sub は derive しない — score の足し算に domain meaning はない。DefaultAdlScore(0) =「何も勝っていない」sentinel として意味を持つ。

Step 3: AdlRecord を追加

/// Per-account record of one ADL force-close.
///
/// The bridge applies these as bookkeeping mutations: credit the
/// trader's collateral by `pnl_paid`, set their position size to zero,
/// remove the account from the open-positions table. `close_order`
/// carries the spec for parity with Liquidation参照実装(計算パート)'s other paths and for
/// telemetry; the matching engine is **not** consulted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AdlRecord {
    pub account: AccountId,
    /// The (notional) close-order spec; emitted for telemetry and shape
    /// consistency with [`crate::scanner::LiquidationRecord`]. The
    /// bridge does NOT submit this to the CLOB.
    pub close_order: CloseOrderSpec,
    /// Unrealized `PnL` at the current mark — what the trader would
    /// have received in a normal close.
    pub pnl_gross: i64,
    /// Amount the system kept toward absorbing the deficit
    /// (`min(remaining_deficit, pnl_gross)` at the time this record
    /// was generated).
    pub haircut: i64,
    /// What the trader actually receives. Always `pnl_gross - haircut`,
    /// always `≥ 0`.
    pub pnl_paid: i64,
    /// The ranking score at the moment of selection.
    pub score: AdlScore,
}

要点: pnl_paid = pnl_gross - haircut は 1 record の保存則 invariant。3 フィールドが同じ情報を 2 回 encode するのは 意図的な冗長性 — audit-trail record は読者に算術を強いない。score: AdlScore(i64 ではない)で「score を balance と比較する」を型が禁じる。

Step 4: AdlReport を追加

/// Summary of one ADL pass.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct AdlReport {
    /// One record per ADL'd account, in execution (rank) order.
    pub records: Vec<AdlRecord>,
    /// Total haircuts applied — how much of the input deficit was
    /// absorbed.
    pub deficit_absorbed: i64,
    /// What the candidate pool couldn't cover. If `> 0`, the chain
    /// must halt or the operator must accept the residual as protocol
    /// loss.
    pub deficit_remaining: i64,
}

要点: ScanReport と同じ shape(vec + i64 aggregate)。Default derive で L2 の「deficit ゼロ」early-return が AdlReport::default() 1 行で書ける。Vec を持つので Copy は付けない。

Step 5: adl_score を追加

/// Compute the ADL score for one account at `mark`.
///
/// Returns `None` for accounts that are not eligible for ADL:
///   - Non-profitable positions (`unrealized_pnl ≤ 0`).
///   - Flat positions (`position_size == 0`).
///   - Accounts whose collateral or equity is zero (degenerate;
///     score's divisor would be zero or negative).
#[must_use]
pub fn adl_score(snapshot: &AccountSnapshot, mark: MarkPrice) -> Option<AdlScore> {
    if snapshot.position_size.0 == 0 {
        return None;
    }
    let pnl = unrealized_pnl(snapshot, mark);
    if pnl <= 0 {
        return None;
    }
    let collateral = snapshot.collateral.0;
    if collateral <= 0 {
        return None;
    }
    let equity = account_equity(snapshot, mark);
    if equity <= 0 {
        return None;
    }
    let notional = notional_value(snapshot, mark);

    // pnl_pct_bps = pnl × MARGIN_SCALE / collateral
    let pnl_pct = i128::from(pnl).saturating_mul(i128::from(MARGIN_SCALE))
        / i128::from(collateral);
    // leverage_bps = notional × MARGIN_SCALE / equity
    let leverage = i128::from(notional).saturating_mul(i128::from(MARGIN_SCALE))
        / i128::from(equity);
    // score = pnl_pct × leverage / MARGIN_SCALE (renormalize)
    let raw = pnl_pct.saturating_mul(leverage) / i128::from(MARGIN_SCALE);
    Some(AdlScore(saturate_i128_to_i64(raw)))
}

要点: 4 つの early-return guard はコスト昇順(flat 比較 → pnl → collateral → equity)。すべて <= 0< 0 ではない)— ゼロは候補状態ではない。全 arithmetic で i128 中間値 + saturating、最後の saturate_i128_to_i64 だけが width-narrowing で情報を失いうる。division は正-正なので素の /(overflow しえない)。tunable knob がないので LiquidationParams は取らない(未使用 parameter を pre-add しない)。

Step 6: 5 個の unit test を追加(adl.rs 末尾に #[cfg(test)] mod tests

#[cfg(test)]
mod tests {
    use super::*;
    use openhl_funding::{Notional, PositionSize};
    use proptest::prelude::*;

    fn snapshot(account: u64, size: i64, entry: u64, collateral: i64) -> AccountSnapshot {
        AccountSnapshot {
            account: AccountId(account),
            position_size: PositionSize(size),
            avg_entry: MarkPrice(entry),
            collateral: Notional(collateral),
        }
    }

    // ─── adl_score: None cases ─────────────────────────────────────

    #[test]
    fn score_none_for_flat_position() {
        let s = snapshot(1, 0, 100, 1_000);
        assert_eq!(adl_score(&s, MarkPrice(100)), None);
    }

    #[test]
    fn score_none_for_losing_long() {
        // Long 1 @ 100, mark 80 → pnl = -20 → not eligible
        let s = snapshot(1, 1, 100, 1_000);
        assert_eq!(adl_score(&s, MarkPrice(80)), None);
    }

    #[test]
    fn score_none_for_short_at_entry() {
        // pnl = 0, not profitable.
        let s = snapshot(1, -1, 100, 1_000);
        assert_eq!(adl_score(&s, MarkPrice(100)), None);
    }

    #[test]
    fn score_none_for_zero_collateral() {
        let s = snapshot(1, 1, 100, 0);
        // Even if profitable at mark 120, collateral = 0 makes pnl_pct
        // undefined (divide by zero) → ineligible.
        assert_eq!(adl_score(&s, MarkPrice(120)), None);
    }

    // ─── adl_score: ordering ───────────────────────────────────────

    #[test]
    fn score_higher_for_higher_leverage_winner() {
        // Two profitable longs with the same pnl_pct but different
        // leverage. Higher leverage → higher score.
        // Long 1 @ entry 100, mark 200 → pnl = 100.
        // A: collateral 100, equity = 100 + 100 = 200, notional = 200, leverage = 1×
        //    pnl_pct_bps = 100 × 10_000 / 100 = 10_000
        //    leverage_bps = 200 × 10_000 / 200 = 10_000
        //    score = 10_000 × 10_000 / 10_000 = 10_000
        // B: collateral 50, equity = 50 + 100 = 150, notional = 200, leverage = ~1.33×
        //    pnl_pct_bps = 100 × 10_000 / 50 = 20_000
        //    leverage_bps = 200 × 10_000 / 150 = 13_333
        //    score = 20_000 × 13_333 / 10_000 = 26_666
        let a = snapshot(1, 1, 100, 100);
        let b = snapshot(2, 1, 100, 50);
        let sa = adl_score(&a, MarkPrice(200)).unwrap();
        let sb = adl_score(&b, MarkPrice(200)).unwrap();
        assert!(sb > sa, "higher leverage winner should rank above lower");
    }
}

要点: 4 つの None テストが eligibility filter の各 branch を、ordering テストが score の唯一の load-bearing 性質(relative magnitude)を exercise する。ordering は assert!(sb > sa)assert_eq! ではない)— 正確な値は rounding に fragile だが ordering は不変。コメントの math-walk が各テストを worked example に変える。proptest::prelude::* はレッスン4 用に forward-staged。

Step 7: lib.rs を配線(3 編集)

pub mod adl; を alphabetical に挿入し、再 export を 1 行追加:

pub mod adl;
pub mod compute;
pub mod insurance;
pub mod scanner;
pub mod types;

pub use adl::{adl_score, AdlRecord, AdlReport, AdlScore};
pub use compute::{
    account_equity, close_order_spec, liquidation_fee, margin_health, margin_ratio,
    notional_value, solvent_close_outcome, underwater_close_outcome, unrealized_pnl,
};
pub use insurance::{InsuranceFund, WithdrawOutcome};
pub use scanner::{CloseOutcomeKind, LiquidationRecord, LiquidationScanner, ScanReport};
pub use types::{
    AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, SolventClose,
    UnderwaterClose, MARGIN_SCALE,
};

答え合わせ

cd ~/code/openhl-reference && git checkout d66b44a
diff -u ~/code/my-openhl/crates/liquidation/src/adl.rs ./crates/liquidation/src/adl.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs

レッスン1 後、adl.rs は参照実装の score_higher_for_higher_leverage_winner テストまで一致(execute_adl と残り 16 テストは L2/L3/L4)。lib.rspub mod adl; + 再 export についてバイト単位で一致。

予測の検算: 4 トレーダーの正確な順は B → A → D → C(B が最高 leverage の profitable winner)。

合格基準

cargo test -p openhl-liquidation

74 テスト pass(Liquidation 由来 69 + 新規 ADL score 5)。

落ちる主因: import から 5 名のいずれか欠落 / pnl <= 0pnl < 0 と書いて score_none_for_short_at_entry が落ちる / division 順を pnl × (MARGIN_SCALE / collateral) と書いて truncation で ordering が flip。

まとめ(3行)

  • score は newtype AdlScore(i64) — ordering を enable し arithmetic を forbid する。
  • 不適格は OptionNone(sentinel ではない)で、L2 が filter_map で一掃できる。
  • score = pnl_pct × leverage、i128 中間値で計算し最後だけ i64 に saturate。