FABRKNT
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
Scanner & capstone
レッスン 13 / 14·CONTENT35 分70 XP
コース
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
レッスンの役割
CONTENT
順序
13 / 14

レッスン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 がアーキテクチャ。
  • MarginHealthmatch + 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_desiredsolvent_close_outcome に route、elseunderwater_close_outcome。caller の runtime predicate が callee の compile-time 契約と同一。
  • underwater 分岐の WithdrawOutcome match が レッスン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 の設計契約)。

失敗例(誤解)

scaniter().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 LiquidationScannerinto_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 solventfee_to_fund を 3 回読む — Copy で無料、fee_to_fund==0 チェックなし — 型契約が排除)。⑤b underwaterif 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_nothing2 件 使う(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 != 0ScanReport::default() の derivation が間違い)/ Copy trait bound 不満(SolventClose/UnderwaterCloseCopy か確認)。

まとめ(3行)

  • scan は thin orchestrator(全行が 計算パート/保険基金パート プリミティブを呼ぶか saturating_add を apply するだけ、新しい数学・ポリシー・データ shape なし)。
  • exhaustive match が predicate-with-! に勝つ(フェーズ 1 の MarginHealth match が将来の 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 一致させる。