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

レッスン3 — 6 つの nuanced absorption テスト

問い

L2 で 5 つの degenerate-path テストを ship した。では execute_adlinteresting な中域(複数 winner が deficit を分け合う、tie が break される、loser と flat が winner と共存する)を、どんな入力で証明すればいいのか?

原理(最小モデル)

6 テストは 2 軸マトリクス {single winner, multiple winners} × {full absorb, partial absorb, mixed eligibility} の各セルだ。

  • 各テストは コメントに math を書いた hand-computed worked example(black-box assertion ではない)。
  • テスト名は adl_<scenario>_<expected_outcome> 規約 — cargo test --list が関数の 仕様 になる。
  • 進展は simple → compound: single-winner edge → multi-winner ordering → tiebreaker → mixed-eligibility integration。

具体例

A(coll 100、score 10_000)と B(coll 50、score 26_666)、deficit = 80:

sort → [B, A]   (B のほうが高 score)
B haircut = min(80, 100) = 80 → remaining = 0 → break
A は loop body に入らない → record なし
report.records.len() == 1   (B のみ)

失敗例(誤解)

「1 関数に 6 テストは過剰」は誤り。execute_adl は 5 phase × 複数の入力次元(deficit size / candidate count / score 分布 / eligibility mix)を持つ。マトリクスは 6 よりずっと大きく、16 unit test は「最小カバレッジ」に近い。


ここまでで「マトリクスのどのセルを埋めるか」は着地した。ここから 6 テストを worked example として組み立てる。

🛑 予測。 execute_adl([A_score_10000, B_score_26666], deficit=80) で A の record が zero-haircut として含まれず、record 自体が無い のはなぜか?

(答え: Phase 4 の if remaining <= 0 break; が、A が処理される に loop を exit するから。B が 80 で haircut された後 remaining = 0 → break → A は loop body に入らない。break が report の record count を「実際に force-close された数」として意味あるものに保つ。

ステップで組み立てる(既存 mod tests に 6 テスト append)

Test 1: full-PnL haircut で payout がゼロ

#[test]
fn adl_single_winner_partial_haircut_at_full_pnl() {
    // PnL = 100, deficit = 100 → full haircut, payout = 0.
    let candidates = vec![snapshot(1, 1, 100, 100)];
    let report = execute_adl(&candidates, MarkPrice(200), 100);
    let rec = &report.records[0];
    assert_eq!(rec.haircut, 100);
    assert_eq!(rec.pnl_paid, 0);
    assert_eq!(report.deficit_remaining, 0);
}

haircut == pnl_gross の boundary。winner がすべてを支払い payout = 0、だが deficit はちょうど covered で remaining = 0。

Test 2: Deficit が winner の PnL を超える — remainder が propagate

#[test]
fn adl_single_winner_exhausted_with_remaining_deficit() {
    // PnL = 100, deficit = 250 → full haircut, 150 remains.
    let candidates = vec![snapshot(1, 1, 100, 100)];
    let report = execute_adl(&candidates, MarkPrice(200), 250);
    assert_eq!(report.records.len(), 1);
    assert_eq!(report.deficit_absorbed, 100);
    assert_eq!(report.deficit_remaining, 150);
}

min(250, 100) = 100 — winner の PnL が haircut を cap する。保存則 100 + 150 == 250deficit_remaining = 150 が bridge への「cover できなかった」シグナル(policy は bridge の責任)。

Test 3: Multi-winner ranking — より高い leverage が勝つ

#[test]
fn adl_multiple_winners_in_score_order() {
    // Two long winners; the higher-leverage one ranks first.
    // A: coll 100, pnl 100 → score 10_000 (per レッスン1's score derivation)
    // B: coll 50,  pnl 100 → score 26_666
    // deficit = 80 → B haircut = 80, pnl_paid = 20; A untouched.
    let candidates = vec![snapshot(1, 1, 100, 100), snapshot(2, 1, 100, 50)];
    let report = execute_adl(&candidates, MarkPrice(200), 80);
    assert_eq!(report.records.len(), 1, "deficit smaller than B's pnl → only B");
    assert_eq!(report.records[0].account, AccountId(2));
    assert_eq!(report.records[0].haircut, 80);
}

同じ PnL でも B の高 leverage が score を押し上げ rank 1 に。score-math コメントがテストの spec。A の record が absent なのは予測 callout の payoff。

Test 4: Deficit が rank 1 を drain し rank 2 を partial に cover

#[test]
fn adl_drains_first_winner_then_partially_second() {
    // Both winners contribute to a large deficit.
    // A: coll 100, pnl 100 → score 10_000, rank #2
    // B: coll 50,  pnl 100 → score 26_666, rank #1
    // deficit = 150 → B haircut = 100 (full), A haircut = 50 (partial)
    let candidates = vec![snapshot(1, 1, 100, 100), snapshot(2, 1, 100, 50)];
    let report = execute_adl(&candidates, MarkPrice(200), 150);
    assert_eq!(report.records.len(), 2);
    assert_eq!(report.records[0].account, AccountId(2)); // B first
    assert_eq!(report.records[0].haircut, 100);
    assert_eq!(report.records[0].pnl_paid, 0);
    assert_eq!(report.records[1].account, AccountId(1)); // A second
    assert_eq!(report.records[1].haircut, 50);
    assert_eq!(report.records[1].pnl_paid, 50);
    assert_eq!(report.deficit_absorbed, 150);
    assert_eq!(report.deficit_remaining, 0);
}

Test 3 と同じ入力、大きい deficit(150)で quota exhaustion を isolate。B 完全 haircut(100) → A residual(50)。保存則 100 + 50 + 0 == 150。順序 assertion がソート規律を将来の refactor から守る。

Test 5: Equal score → ascending account_id が勝つ

#[test]
fn adl_tiebreaker_by_account_id_ascending() {
    // Two structurally identical winners. Tiebreaker is account_id
    // ascending → smaller account_id is force-closed first.
    let candidates = vec![
        snapshot(7, 1, 100, 50),  // identical except account
        snapshot(3, 1, 100, 50),
    ];
    let report = execute_adl(&candidates, MarkPrice(200), 50);
    assert_eq!(report.records.len(), 1);
    assert_eq!(report.records[0].account, AccountId(3));
}

account_id 以外を identical に揃え tiebreaker だけを isolate。入力 vector で 2 番目に渡した AccountId(3) が勝つ — 入力順ではなくソート規律(then_with ascending)が matter する。

Test 6: Mixed population で loser と flat が filter される

#[test]
fn adl_does_not_touch_losers_or_flats() {
    let candidates = vec![
        snapshot(1, 1, 100, 50),     // winner @ mark 200
        snapshot(2, 1, 100, 1_000),  // loser? — same mark applies, see below
        snapshot(3, 0, 100, 1_000),  // flat
    ];
    // All evaluated at mark = 200 → only acct 1 is profitable.
    let report = execute_adl(&candidates, MarkPrice(200), 10);
    assert_eq!(report.records.len(), 1);
    assert_eq!(report.records[0].account, AccountId(1));
}

acct 3 は flat → Phase 2 で filter_map が drop。acct 2 は profitable だが collateral が高く低 score → deficit = 10 が acct 1 で exhaust され Phase 4 の break で touch されない。1 テストが 2 つの防衛 layer(eligibility filter + quota break)を同時に証明する。 コメントの // loser? は drafting 履歴を honest に残したもの。

テストの進展を俯瞰

   simple ─────────────────────────────────────────► compound
   Test 1,2 ── single winner edge ──── Phase 4 (boundary)
   Test 3,4 ── multi-winner ordering ─ Phase 3 (sort) + Phase 4 (break)
   Test 5   ── tiebreaker isolation ── Phase 3 (then_with, 決定論性)
   Test 6   ── mixed integration ───── Phase 2 + Phase 4 composition

答え合わせ

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

6 nuanced テストまで参照実装と一致するはず(残り 5 proptest は L4)。

合格基準

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

16 テスト pass(L1 の 5 + L2 の 5 + L3 の 6)。Full な ADL unit-test マトリクスが特定入力に対してカバーされる。

まとめ(3行)

  • 6 テストは 2 軸マトリクスのセル — single/multi winner × full/partial/mixed。
  • 期待値はコメントに math-walk で hand-compute(assertion を proof に変える)。
  • Test 6 が eligibility filter と quota break の 2 層を 1 つの mixed 入力で証明する。