レッスン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 <= 0で break。 - 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_adl は remaining <= 0 で break を持つ。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 しない)。*sはCopyなAccountSnapshotを 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_noop | 1 (early-return) | ゼロ入力 → 空 report、候補に触れない |
adl_negative_deficit_clamps_remaining_to_zero | 1 (negative clamp) | deficit.max(0) が負のリークを防ぐ |
adl_no_candidates_keeps_full_deficit | 2 (空入力) | 空候補 → ループ 0 回 → remaining 不変 |
adl_no_profitable_keeps_full_deficit | 2 (全 filter) | 全候補 None → ranked 空 → remaining 不変 |
adl_single_winner_fully_absorbs_small_deficit | 4 (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 <= 0でbreakする(scanner のscanとの構造的差)。 - 保存則
deficit_absorbed + deficit_remaining == deficitはループ本体が構造的に保つ。