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

レッスン2 — execute_adl — 5 フェーズの orchestration 中枢

問い

scanner が赤字を残したとき、利益 winner たちを 誰から、どの順で、どれだけ haircut するのか? それを 1 つの決定論的な関数にどう畳むか?

原理(最小モデル)

execute_adl(candidates, mark, deficit) -> AdlReport は 5 フェーズのパイプラインだ。

  • Phase 1: 非正の deficit に early-return(防御的契約)。
  • Phase 2: adl_score で各候補を scoring、ineligible(None) を filter_map で drop、(snapshot, score, pnl) を collect。
  • Phase 3: (score 降順, account_id 昇順) で stable-sort。tiebreaker が決定論性の鍵。
  • Phase 4: rank 順に iterate し haircut = min(remaining, pnl_gross) を適用、remaining <= 0break
  • Phase 5: deficit_remaining = remaining を finalize して return。

核心の性質は 2 つ。保存則 deficit_absorbed + deficit_remaining == input_deficit はループ本体が構造的に保つ(L4 で証明)。quota-bound loop は break を持つ — deficit が覆われたら止まる。

具体例

単一 winner(pnl_gross = 100)、deficit = 30:

haircut  = min(30, 100) = 30
pnl_paid = 100 - 30      = 70
report   = { records: [1件], deficit_absorbed: 30, deficit_remaining: 0 }

失敗例(誤解)

「scanner の scan と同じく全候補を処理すればいい」は誤り。scanner は quota なし(全アカウントを measure する)。ADL は quota-bound — deficit が覆われたら break する。break がないと 100 候補のうち 1 人で足りる場面でも 100 全部を歩き、report の records.len() が「実際に force-close された数」を意味しなくなる。


ここまでで「5 フェーズと quota-bound break」は着地した。ここから先は実際の execute_adl を組み立てる。コードは完全形。

🛑 予測。 下を読む前に: scanner の scan(Liquidation レッスン13)と execute_adl構造的な違いを 1 つ 予想せよ。ヒント: ADL は quota(deficit)を持ち、scanner は持たない。

(答え: execute_adlremaining <= 0break を持つ。scan は持たない。 quota-bound loop は break を持ち、quota-free loop は全件処理する。break は performance にも効く — 最初の winner で足りれば 1 人だけ haircut すれば済む。)

ステップで組み立てる

execute_adl のソース全体(adl_score の下に append)

/// Execute one ADL pass over the candidate set.
///
/// Pipeline:
///   1. Filter to ADL-eligible accounts (see [`adl_score`]).
///   2. Stable-sort by score descending; ties break by `AccountId`
///      ascending so two equally-ranked accounts produce a
///      deterministic order.
///   3. Iterate, applying `haircut = min(remaining_deficit, pnl_gross)`
///      to each in rank order. Stop when `remaining_deficit == 0` or
///      candidates are exhausted.
///
/// Returns an [`AdlReport`] whose `deficit_absorbed + deficit_remaining`
/// equals the input `deficit` (modulo saturating arithmetic).
///
/// A non-positive `deficit` is treated as "nothing to do" — returns an
/// empty report.
#[must_use]
pub fn execute_adl(
    candidates: &[AccountSnapshot],
    mark: MarkPrice,
    deficit: i64,
) -> AdlReport {
    // Phase 1: defensive early-return for non-positive deficit.
    if deficit <= 0 {
        return AdlReport {
            records: Vec::new(),
            deficit_absorbed: 0,
            deficit_remaining: deficit.max(0),
        };
    }

    // Phase 2: score every candidate, drop the ineligible, keep (snapshot, score, pnl).
    let mut ranked: Vec<(AccountSnapshot, AdlScore, i64)> = candidates
        .iter()
        .filter_map(|s| {
            let score = adl_score(s, mark)?;
            let pnl = unrealized_pnl(s, mark);
            Some((*s, score, pnl))
        })
        .collect();

    // Phase 3: stable sort by (score desc, account_id asc).
    ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.account.0.cmp(&b.0.account.0)));

    // Phase 4: iterate and haircut until deficit absorbed or candidates exhausted.
    let mut report = AdlReport::default();
    let mut remaining = deficit;
    for (snapshot, score, pnl_gross) in ranked {
        if remaining <= 0 {
            break;
        }
        let haircut = remaining.min(pnl_gross);
        let pnl_paid = pnl_gross.saturating_sub(haircut);
        report.records.push(AdlRecord {
            account: snapshot.account,
            close_order: close_order_spec(&snapshot),
            pnl_gross,
            haircut,
            pnl_paid,
            score,
        });
        report.deficit_absorbed = report.deficit_absorbed.saturating_add(haircut);
        remaining = remaining.saturating_sub(haircut);
    }

    // Phase 5: finalize deficit_remaining and return.
    report.deficit_remaining = remaining;
    report
}

フェーズ別の要点

  • Phase 1: deficit <= 0 が zero と negative の両方をカバー。deficit.max(0) が負の remainder の下流伝播を防ぐ(境界で clamp するほうが下流全部を直すより安い)。
  • Phase 2: filter_map は「filter して unwrap、1 パス」。クロージャ内の adl_score(s, mark)?その要素だけ を short-circuit して None(drop)を返す — iterator 全体を break しない。unrealized_pnl は filter pass の で計算(捨てるかもしれない量を先に decide しない)。*sCopyAccountSnapshot を deref して値で持つ。
  • Phase 3: b.1.cmp(&a.1) が降順(暗記パターン)。.then_with は lazy — score tie のときだけ tiebreaker クロージャが走る。tiebreaker(account_id 昇順) は「fair」ではなく validator 間で reproducible にするため。sort_by(stable) を選ぶのは決定論性のため(候補数 0..15 では一時メモリコストは無視できる)。
  • Phase 4: if remaining <= 0 break; が quota guard。haircut = remaining.min(pnl_gross) は「deficit が要る分か winner が持つ分の小さい方」。全 +/-saturating_* で書く(overflow は構造上不可能だが、1 関数内で規律を一貫させ読者に明白にする)。
  • Phase 5: deficit_remaining はループ に 1 回だけ set。report は trailing expression で return(return キーワードなし)。関数は #[must_use] — bridge が report を drop したらコンパイラが警告。

5 個の unit test(既存 mod tests に append)

#[test]
fn adl_zero_deficit_is_noop() {
    let candidates = vec![snapshot(1, 1, 100, 100)];
    let report = execute_adl(&candidates, MarkPrice(200), 0);
    assert!(report.records.is_empty());
    assert_eq!(report.deficit_absorbed, 0);
    assert_eq!(report.deficit_remaining, 0);
}

#[test]
fn adl_negative_deficit_clamps_remaining_to_zero() {
    // Defensive: a negative deficit can't be "absorbed" but also
    // shouldn't propagate as a negative remainder.
    let report = execute_adl(&[], MarkPrice(100), -50);
    assert_eq!(report.deficit_remaining, 0);
}

#[test]
fn adl_no_candidates_keeps_full_deficit() {
    let report = execute_adl(&[], MarkPrice(100), 5_000);
    assert!(report.records.is_empty());
    assert_eq!(report.deficit_absorbed, 0);
    assert_eq!(report.deficit_remaining, 5_000);
}

#[test]
fn adl_no_profitable_keeps_full_deficit() {
    // All candidates are losers (long entered at 100, mark 80).
    let candidates = vec![snapshot(1, 1, 100, 1_000), snapshot(2, 1, 100, 1_000)];
    let report = execute_adl(&candidates, MarkPrice(80), 500);
    assert!(report.records.is_empty());
    assert_eq!(report.deficit_remaining, 500);
}

#[test]
fn adl_single_winner_fully_absorbs_small_deficit() {
    // One profitable long with PnL = 100, deficit = 30.
    // haircut = min(30, 100) = 30; payout = 70.
    let candidates = vec![snapshot(1, 1, 100, 100)];
    let report = execute_adl(&candidates, MarkPrice(200), 30);
    assert_eq!(report.records.len(), 1);
    let rec = &report.records[0];
    assert_eq!(rec.pnl_gross, 100);
    assert_eq!(rec.haircut, 30);
    assert_eq!(rec.pnl_paid, 70);
    assert_eq!(report.deficit_absorbed, 30);
    assert_eq!(report.deficit_remaining, 0);
}

各テストが phase / 境界に 1:1 で map する:

テストPhase証明
adl_zero_deficit_is_noop1 (early-return)ゼロ入力 → 空 report、候補に触れない
adl_negative_deficit_clamps_remaining_to_zero1 (negative clamp)deficit.max(0) が負のリークを防ぐ
adl_no_candidates_keeps_full_deficit2 (空入力)空候補 → ループ 0 回 → remaining 不変
adl_no_profitable_keeps_full_deficit2 (全 filter)全候補 None → ranked 空 → remaining 不変
adl_single_winner_fully_absorbs_small_deficit4 (happy path)単一 iteration、haircut = min、decomposition 成立

答え合わせ

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

execute_adl 関数本体 + 5 unit test まで参照実装と一致するはず(残り 6 nuanced テストと 5 proptest は L3/L4)。

合格基準

cargo test -p openhl-liquidation adl::tests::adl_

79 テスト pass(L1 の 74 + L2 の 5)。これで scanner が ADL に対して runnable になる。

まとめ(3行)

  • execute_adl は 5 フェーズ: early-return → score+filter → sort → haircut loop → finalize。
  • quota-bound loop なので remaining <= 0break する(scanner の scan との構造的差)。
  • 保存則 deficit_absorbed + deficit_remaining == deficit はループ本体が構造的に保つ。