レッスン12 — scan — safety cascade のオーケストレーションの心臓
問い
レッスン11 で状態を記述する型 4 つを宣言した。今度はそれらを生む 1 つのメソッド scan を書く。&[AccountSnapshot] を順に辿り、分類し、Liquidatable/Underwater に dispatch し、fund を mutate し、ScanReport を返す。「liquidate 対象でないアカウントを skip」と「solvent vs underwater の dispatch」を最もきれいに書くには?
原理(最小モデル)
scanは orchestration 層で 唯一の動詞、他はすべて名詞。(accounts, mark)を取りScanReportを返す。本体内で 計算パート + 保険基金パート プリミティブが liquidate 対象 1 件あたりちょうど 1 度ずつ呼ばれる。composition がアーキテクチャ。MarginHealthのmatch+continue-guard が「skip」の最もきれいなパターン。if !matches!(c, Liquidatable | Underwater) { continue; }のほうが短いが exhaustiveness を失う。match形は compiler に「全 variant が考慮されたか」を enforce させる(将来 5 つ目の variant が追加されたときに bug を捕まえる)。- solvent vs underwater dispatch は レッスン10 の
debug_assert!ペアを直接 mirror。if post_close_equity >= fee_desiredがsolvent_close_outcomeに route、elseがunderwater_close_outcome。caller の runtime predicate が callee の compile-time 契約と同一。 - underwater 分岐の
WithdrawOutcomematch が レッスン9 enum を(paid, unfilled)タプルに分解する。 solvent close はdepositだけ(withdraw_shortfallを触らない)。orchestration 層が レッスン9 variant と レッスン11 i64 aggregate の間を 1 つの match で翻訳。
具体例
scan の shape:
scan(accounts, mark) → ScanReport
let mut report = ScanReport::default();
for snapshot in accounts {
match margin_health(...) {
Safe | AtRisk => continue, ← skip 1
Liquidatable | Underwater => {} ← work
}
if position_size == 0 { continue; } ← skip 2 (defensive)
let close_order = close_order_spec(snapshot);
let outcome = if post_close_equity >= fee_desired {
solvent_close_outcome → fund.deposit → report.fund_deposits += / Solvent(s)
} else {
underwater_close_outcome → (deposit if fee>0) → withdraw_shortfall
→ match WithdrawOutcome → (paid, unfilled) → aggregate / Underwater(u)
};
report.records.push(LiquidationRecord { ... });
}
report
外側は for(各 iteration が side effects を持つので iterator chain でない)。2 つの continue は loop body の先頭(happy path は同じ indent level に inline、ネストしない)。aggregate は per-iteration の saturating_add(レッスン11 の設計契約)。
失敗例(誤解)
「scan を iter().filter_map(...).collect() で書くべき」は誤り、問題 2 つ: (1) self.fund を mutate する closure への filter_map は iterator chain 全体で self を排他 borrow し、closure capture と衝突(borrow checker が reject)。(2) compile が通っても per-iteration の side effects(deposit/withdraw/aggregate-add)を map closure 内に隠す。&mut self を capture する for loop は、本体が enclosing self を mutate するとき iterator chain に勝つ。
ここまでで「scan は thin orchestrator」は着地した。ここで scanner が runnable になる(レッスン11 の staged import がついに consumer を得て unused-import 警告が消える)。コードは完全形。
🛑 予測。 スライス内のアカウントごとに liquidate するか skip するかを決める関数で、必要な分岐をリストアップする。skip は何個、work は何個?(答え: 厳密に 2 つの
continue(Safe/AtRisk、flat-position)と 2 つの routing(solvent vs underwater)= 4 分岐。underwater 分岐は positive/zero/negative equity の 3 サブケースを 1 回のunderwater_close_outcome呼び出しに統合する(レッスン10 が済ませた)。callee 内でサブケースを encapsulate すれば caller の分岐数が縮む。)
ステップで組み立てる
Step 1: scan メソッドを追加
impl LiquidationScanner の into_fund の後に:
/// Scan every account and produce a [`ScanReport`] of the resulting
/// liquidations.
///
/// All accounts are classified at the given `mark`. Liquidatable and
/// Underwater accounts are converted to close orders + outcomes,
/// with the insurance fund mutated in place. `Safe` and `AtRisk`
/// accounts produce no record and no fund mutation.
///
/// Flat positions (`position_size == 0`) that misclassify as
/// Liquidatable are also skipped — `close_order_spec` would emit a
/// zero-qty spec which the CLOB rejects.
pub fn scan(
&mut self,
accounts: &[AccountSnapshot],
mark: MarkPrice,
) -> ScanReport {
let mut report = ScanReport::default();
for snapshot in accounts {
let classification = margin_health(snapshot, mark, &self.params);
match classification {
MarginHealth::Safe | MarginHealth::AtRisk => continue,
MarginHealth::Liquidatable | MarginHealth::Underwater => {}
}
// Skip flat positions defensively — the upstream
// classification should never put them here, but the math
// for a zero-size position produces a zero-qty close order
// which the CLOB rejects.
if snapshot.position_size.0 == 0 {
continue;
}
let close_order = close_order_spec(snapshot);
// Decide solvent vs underwater path on post-close-equity vs
// desired fee, exactly mirroring the compute module's
// contract.
let notional = notional_value(snapshot, mark);
let fee_desired = liquidation_fee(notional, &self.params);
let post_close_equity = account_equity(snapshot, mark);
let outcome = if post_close_equity >= fee_desired {
let solvent = solvent_close_outcome(snapshot, mark, &self.params);
self.fund.deposit(solvent.fee_to_fund);
report.fund_deposits =
report.fund_deposits.saturating_add(solvent.fee_to_fund);
CloseOutcomeKind::Solvent(solvent)
} else {
let underwater = underwater_close_outcome(snapshot, mark, &self.params);
if underwater.fee_to_fund > 0 {
self.fund.deposit(underwater.fee_to_fund);
report.fund_deposits = report
.fund_deposits
.saturating_add(underwater.fee_to_fund);
}
let withdraw = self.fund.withdraw_shortfall(underwater.shortfall_to_fund);
let (paid, unfilled) = match withdraw {
WithdrawOutcome::Covered { amount } => (amount, 0),
WithdrawOutcome::PartiallyDrained { amount, unfilled } => {
(amount, unfilled)
}
WithdrawOutcome::Depleted { unfilled } => (0, unfilled),
};
report.fund_withdrawals = report.fund_withdrawals.saturating_add(paid);
report.unfilled_deficit = report.unfilled_deficit.saturating_add(unfilled);
CloseOutcomeKind::Underwater(underwater)
};
report.records.push(LiquidationRecord {
account: snapshot.account,
close_order,
classification,
outcome,
});
}
report
}
フェーズ別: ① 分類(match は exhaustive、compiler が enforce — 5 つ目の variant 追加で compile 失敗、work-path arm は {} で fall-through、or-pattern で skip 2 ケースを統合)。② defensive flat-skip(理論上不可能だが bridge が sanitize されない snapshot を submit しうる、zero-qty spec を CLOB が reject)。③ close order 生成(1 行、レッスン7 の pure 関数)。④ routing(predicate は レッスン10 の debug_assert! の正反対、3 ローカル変数で predicate に到達、各 callee を別分岐で呼ぶ — 統合すると precondition 違反で debug_assert! 発火)。⑤a solvent(fee_to_fund を 3 回読む — Copy で無料、fee_to_fund==0 チェックなし — 型契約が排除)。⑤b underwater(if fee_to_fund > 0 guard — レッスン10 が 0 を返しうる、WithdrawOutcome match で (paid, unfilled) に分解 — 3 variant が 1 タプルに collapse、保存則 amount + unfilled = shortfall が引き継がれる)。⑥ push(毎 iteration 終わりに 1 push)。
Step 2: テストモジュールの足場
scanner.rs 末尾に:
#[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),
}
}
fn default_params() -> LiquidationParams {
LiquidationParams::hyperliquid_default()
}
// ─── empty / non-liquidatable input ────────────────────────────
use proptest::prelude::* は レッスン13 用に staged。snapshot ヘルパー(レッスン4 の compute::tests を mirror)。
Step 3: 4 つの simple unit test
#[test]
fn scan_empty_accounts_returns_empty_report() {
let mut s = LiquidationScanner::with_empty_fund(default_params());
let report = s.scan(&[], MarkPrice(100));
assert!(report.records.is_empty());
assert_eq!(report.fund_deposits, 0);
assert_eq!(report.fund_withdrawals, 0);
assert_eq!(report.unfilled_deficit, 0);
}
#[test]
fn scan_all_safe_accounts_does_nothing() {
// Long 1 @ $100k, $50k collateral, mark $100k → 50% ratio = Safe.
let accts = vec![
snapshot(1, 1, 100_000, 50_000),
snapshot(2, 1, 100_000, 50_000),
];
let mut s = LiquidationScanner::with_empty_fund(default_params());
let report = s.scan(&accts, MarkPrice(100_000));
assert!(report.records.is_empty());
}
#[test]
fn scan_atrisk_does_not_liquidate() {
// Long 1 @ $100k, $5k collateral, mark $100k → 5% ratio
// 5% > 2% maintenance, < 10% initial → AtRisk; no liquidation.
let accts = vec![snapshot(1, 1, 100_000, 5_000)];
let mut s = LiquidationScanner::with_empty_fund(default_params());
let report = s.scan(&accts, MarkPrice(100_000));
assert!(report.records.is_empty());
}
#[test]
fn scan_skips_flat_positions() {
// Flat (size 0) accounts misclassified somewhere upstream get
// silently skipped. Default ratio for flat positions is MAX
// (Safe), so this is also defensive against future
// classification changes.
let accts = vec![snapshot(1, 0, 100_000, 1_000)];
let mut s = LiquidationScanner::with_empty_fund(default_params());
let report = s.scan(&accts, MarkPrice(100_000));
assert!(report.records.is_empty());
}
scan_empty_accounts_returns_empty_report は 4 フィールドすべて assert。scan_all_safe_accounts_does_nothing は 2 件 使う(loop が 2 回 iterate を強制 — single-account は loop-control bug を mask)。scan_atrisk_does_not_liquidate が最も pedagogical に重要(AtRisk は warning state、trigger でない — promote するリファクタが即失敗)。scan_skips_flat_positions がフェーズ 2 の defensive guard を exercise(defense-in-depth テスト)。
Step 4: テスト実行
cargo test -p openhl-liquidation が 59 pass(compute 34 + insurance 21 + scanner 4)。scanner が runnable に。
答え合わせ
cd ~/code/openhl-reference && git checkout 0a8464e
diff -u ~/code/my-openhl/crates/liquidation/src/scanner.rs ./crates/liquidation/src/scanner.rs
git checkout main
scanner.rs は scan_skips_flat_positions まで一致(nuanced test + proptest は レッスン13)。
合格基準
cargo test -p openhl-liquidation が 59 pass。よくあるエラー: cannot find function account_equity(レッスン11 の staged import を消した — 再追加)/ scan_all_safe_accounts_does_nothing 失敗(match arm が Safe | Liquidatable の typo)/ report.fund_deposits != 0(ScanReport::default() の derivation が間違い)/ Copy trait bound 不満(SolventClose/UnderwaterClose が Copy か確認)。
まとめ(3行)
scanは thin orchestrator(全行が 計算パート/保険基金パート プリミティブを呼ぶかsaturating_addを apply するだけ、新しい数学・ポリシー・データ shape なし)。- exhaustive
matchが predicate-with-!に勝つ(フェーズ 1 のMarginHealthmatch が将来の variant 追加を捕まえる — enum と consumer を refactor 越しに同期)。 WithdrawOutcome → (paid, unfilled)タプル分解が レッスン9 enum を orchestration handling に翻訳する唯一の場所(3 variant が 1(i64,i64)に collapse、保存則を引き継ぐ)。&mut selfを capture する for loop が iterator chain に勝つ。
次のレッスン(レッスン13)
セクション4 capstone — そして スキャナパート、そして openhl の Liquidation 実装全体 — を閉じる。6 個の nuanced unit test(4 outcome の single-account + mixed-batch + FIFO fairness)と 4 個の cross-cutting proptest(fund 会計の閉鎖 / unfilled⇒empty / record 数 bound / 決定性)で stress テストし、69 tests で 0a8464e に byte-for-byte 一致させる。