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

レッスン11 — Scanner 型の語彙 — CloseOutcomeKind / LiquidationRecord / ScanReport / LiquidationScanner

問い

計算パートは MarginHealth(per-account 分類)を、保険基金パートは SolventClose/UnderwaterClose(per-close 分解)と WithdrawOutcome(per-fund-call outcome)を生んだ。multi-account scanner が毎ブロック返す batch-level の結果は、どんな型語彙で表す? そして scanner は insurance fund をどう所有する?

原理(最小モデル)

  • orchestration 層には別の型語彙が必要。 スキャナパートは batch-level の型を導入: CloseOutcomeKind(close の kind)/ LiquidationRecord(liquidate 1 件の row)/ ScanReport(1 scan で起きたすべて)。各層が異なる問いに答えるので各層が独自の語彙を持つ。
  • CloseOutcomeKindSolventClose/UnderwaterClose の discriminated union。 variant 2 つ、各々が対応する保険基金パート関数の生んだ struct を運ぶ。scanner が match して post-close 仕事を dispatch。上位層が下位層の 2 出力を route するとき、各出力を運ぶ variant が最もきれいな橋渡し。
  • ScanReport は per-account record の vector AND aggregate 合計の両方。 records = audit trail(iteration 順)、3 つの aggregate i64(fund_deposits/fund_withdrawals/unfilled_deficit)= telemetry summary(bridge が records を iterate せず読める)。scan loop 内で事前計算するのはコスト 0。
  • LiquidationScannerInsuranceFund を直接所有(Arc<Mutex<...>> でない)。 per-bridge コンポーネント、共有リソースでない。ブロックごとにちょうど 1 回 mutate される state machine は同期プリミティブを必要としない。

具体例

型レイヤリング:

   enum CloseOutcomeKind { Solvent(SolventClose), Underwater(UnderwaterClose) }   ← 唯一の新 enum
   struct LiquidationRecord { account, close_order, classification, outcome }     ← per-account
   struct ScanReport { records: Vec<LiquidationRecord>, fund_deposits, fund_withdrawals, unfilled_deficit }  ← per-batch
   struct LiquidationScanner { params, fund: InsuranceFund }   ← 所有、共有でない

CloseOutcomeKind が唯一の新 enum(routing 判断は計算パートの debug_assert! ペアで済んでいる、enum は judgment を carry through するため)。aggregate i64 は scan loop 中に saturating_add で計算(second pass は無駄)。

失敗例(誤解)

「scanner は InsuranceFundArc<Mutex<...>>&'a mut で持つべき」は誤り、問題 3 つ: (1) &'a mut は lifetime parameter を導入し scanner を保持する全型に伝播する。(2) Arc<Mutex<...>> は shared mutable state 用だが scanner は shared でなく bridge が所有(競合のない同期は overhead でしかない)。(3) 値所有なら scanner の lifetime が fund の lifetime、into_fund が shutdown 時のクリーンな handoff を与える。ownership は lifecycle に合わせる(per-bridge、single mutator、shutdown 時 persist)。


ここまでで「orchestration 層は独自の語彙を持つ」は着地した。ここから型語彙を整える(scan メソッドは レッスン12、テストもまだ — 型語彙に testable behavior がない)。コードは完全形。

🛑 予測。 scan が返す ScanReport にどんなフィールドが入るべきか? report 内の per-account record には?(答え: scan report = liquidate 1 件あたり record + fund deposit 合計 + fund 支払い合計 + unfilled deficit 合計。per-account record = account ID + close-order spec + pre-close 分類(traceability)+ post-close outcome 分解。scanner は同じデータの 2 view(CLOB submit 用 records + telemetry/ADL を O(1) で読める aggregate)を bridge に渡す。)

ステップで組み立てる

Step 1: src/scanner.rs を作成(module doc + imports)

//! Multi-account liquidation scanner (スキャナパート).
//!
//! The scanner is the orchestration layer that ties 計算パート (margin
//! classification + close-order generation) and 保険基金パート (insurance
//! fund + close-outcome decomposition) together. The bridge owns a
//! [`LiquidationScanner`], calls [`LiquidationScanner::scan`] once per
//! block (or per market-event tick) with the current accounts and mark,
//! and consumes the returned [`ScanReport`] to (a) submit the close
//! orders to the CLOB and (b) escalate any unfilled deficit.
//!
//! ### Determinism
//!
//! Every validator must produce byte-identical [`ScanReport`]s from the
//! same `(accounts, mark, params, fund_state)`. The scanner only uses
//! `Vec`'s ordered iteration and the fully-deterministic 計算パート/保険基金パート
//! primitives, so determinism follows from caller-side ordering of the
//! accounts slice — **the bridge is responsible for handing accounts in
//! a deterministic order** (typically `account_id`-sorted).
//!
//! ### Fairness when the fund is partially drained
//!
//! When the insurance fund cannot cover every underwater shortfall in
//! one scan, the v0 policy is **first-come-first-served** in iteration
//! order. Earlier-iterated underwater accounts get covered; later ones
//! contribute to [`ScanReport::unfilled_deficit`]. This is the simplest
//! deterministic choice; production fairness designs (pro-rata draw,
//! priority by account leverage) can be layered on later without
//! changing the public type shape.
//!
//! ### ADL handoff
//!
//! [`ScanReport::unfilled_deficit`] is the load-bearing signal that the
//! fund couldn't absorb everything. The scanner records it; a future ADL
//! stage would consume it to drive ADL ranking and force-close
//! profitable counter-positions. Until then, the bridge can either panic
//! on `unfilled_deficit > 0` (conservative — halt the chain) or log and
//! continue (permissive — accept the deficit as a protocol loss).

use crate::compute::{
    account_equity, close_order_spec, liquidation_fee, margin_health, notional_value,
    solvent_close_outcome, underwater_close_outcome,
};
use crate::insurance::{InsuranceFund, WithdrawOutcome};
use crate::types::{
    AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, SolventClose, UnderwaterClose,
};
use openhl_clob::AccountId;
use openhl_funding::MarkPrice;

冒頭 1 文で 誰が何を呼ぶか(bridge が所有・毎ブロック scan を呼び ScanReport を consume)。Determinism セクションが責任を名指す(scanner は決定的、accounts の決定的順序は bridge の責任)。Fairness が v0 ポリシー(FIFO)+ 後継を名指す。import ブロックの広さは意図的(スキャナパート = 計算パート + 保険基金パートのすべて、bill of materials)。

Step 2: CloseOutcomeKind

/// Discriminated outcome for a single liquidated account in a scan.
///
/// `Solvent` carries the [`SolventClose`] decomposition (full fee
/// collectable, residual returns to account). `Underwater` carries the
/// [`UnderwaterClose`] decomposition (partial or zero fee, shortfall the
/// fund must absorb).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CloseOutcomeKind {
    Solvent(SolventClose),
    Underwater(UnderwaterClose),
}

tuple variant(各 variant が 1 payload — Solvent(close)Solvent { close } よりクリーン)。Copy(両 payload が Copy)。doc が 2 payload を名指す(Underwater の「zero fee, full shortfall」サブケースは signature から見えない)。catch-all なし(2-variant = 最小の discriminated dispatch)。

Step 3: LiquidationRecord

/// Per-account record produced by the scanner when an account is
/// liquidated. The bridge submits `close_order` to the CLOB; `outcome`
/// records the credit/debit decomposition the scanner already applied
/// against the [`InsuranceFund`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LiquidationRecord {
    pub account: AccountId,
    pub close_order: CloseOrderSpec,
    /// Pre-close classification from [`margin_health`]. `Liquidatable`
    /// or `Underwater`; `Safe`/`AtRisk` accounts never appear in a
    /// record.
    pub classification: MarginHealth,
    /// Decomposition of what happened in the close. Note that a
    /// `Liquidatable`-classified account can still produce an
    /// `Underwater` outcome when the fee tips post-close equity
    /// negative.
    pub outcome: CloseOutcomeKind,
}

4 フィールド、3 つは既存 Copy 型(新規フィールドなし = 純粋な語彙拡張)。classificationMarginHealth(4 variant を許すが、契約は record に現れるのは 2 つに narrow — doc に書く、別 sub-enum でない)。Liquidatable-classified → Underwater-outcome のノートが key の教授点(classification は pre-close equity、outcome は post-close equity で fee が減らした — レッスン10 の underwater_close_partial_fee_collection がその具体例)。Copy(loop body が ergonomic)。Default なし(中立 state がない)。

Step 4: ScanReport

/// Summary of a single scan pass. Includes per-account records plus
/// aggregate fund-flow totals for telemetry / escalation.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ScanReport {
    /// One record per liquidated account, in scan-iteration order. The
    /// bridge submits each record's `close_order` to the CLOB.
    pub records: Vec<LiquidationRecord>,
    /// Total fees credited to the insurance fund during this scan.
    pub fund_deposits: i64,
    /// Total amount the insurance fund actually paid out (sum of the
    /// `amount` field across `Covered` and `PartiallyDrained`
    /// withdrawals).
    pub fund_withdrawals: i64,
    /// Total shortfall the fund could NOT cover (sum across
    /// `PartiallyDrained.unfilled` and `Depleted.unfilled`). A future
    /// ADL stage consumes this as the ADL trigger.
    pub unfilled_deficit: i64,
}

Clone + Default だが Copy でない(Vec は heap、compiler が enforce)。Default が意味を持つ(empty scan = 全 0、scan が initialize するもの)。Vec の隣の 3 aggregate(bridge の O(n) fold を省く)。fund_withdrawalsamount の合計(支払われた もの、要求された ものでない — partial drain で違う)。unfilled_deficitPartiallyDrained.unfilled AND Depleted.unfilled の合計(= ADL への signal)。

Step 5: LiquidationScanner + accessors

/// Multi-account liquidation scanner.
///
/// Owns an [`InsuranceFund`] and a set of [`LiquidationParams`]. The
/// bridge calls [`Self::scan`] once per block; the scanner classifies
/// every account, generates close orders for the Liquidatable/Underwater
/// ones, mutates the fund accordingly, and returns the resulting
/// [`ScanReport`].
#[derive(Clone, Debug)]
pub struct LiquidationScanner {
    params: LiquidationParams,
    fund: InsuranceFund,
}

impl LiquidationScanner {
    /// Construct a scanner with the given params and a starting fund
    /// balance.
    #[must_use]
    pub const fn new(params: LiquidationParams, fund: InsuranceFund) -> Self {
        Self { params, fund }
    }

    /// Construct a scanner with the given params and an empty insurance
    /// fund. Convenience for tests and fresh-chain bootstrap.
    #[must_use]
    pub const fn with_empty_fund(params: LiquidationParams) -> Self {
        Self {
            params,
            fund: InsuranceFund::empty(),
        }
    }

    /// Current insurance fund balance.
    #[must_use]
    pub const fn fund_balance(&self) -> i64 {
        self.fund.balance()
    }

    /// Borrow the underlying insurance fund (read-only).
    #[must_use]
    pub const fn fund(&self) -> &InsuranceFund {
        &self.fund
    }

    /// Consume the scanner and return its fund — useful for handoff to
    /// snapshot/persistence layers at chain shutdown.
    #[must_use]
    pub fn into_fund(self) -> InsuranceFund {
        self.fund
    }
}

private フィールド 2 つ・public 0(state machine はフィールドを隠す、data carrier は公開する)。Builder でなく 2 コンストラクタ(2 フィールドなら builder に勝つ)。fund_balance(hot-path scalar)+ fund(cold-path full reference)。into_fund は consume-and-extract(shutdown 時の handoff、self を値で取る)。into_fund 以外 const fnset_* なし(mutation は scan 経由のみ、フィールド setter は abstraction-breaking surface)。

Step 6: lib.rs に配線

pub mod compute;
pub mod insurance;
pub mod scanner;
pub mod types;

pub use compute::{
    account_equity, close_order_spec, liquidation_fee, margin_health, margin_ratio,
    notional_value, solvent_close_outcome, underwater_close_outcome, unrealized_pnl,
};
pub use insurance::{InsuranceFund, WithdrawOutcome};
pub use scanner::{CloseOutcomeKind, LiquidationRecord, LiquidationScanner, ScanReport};
pub use types::{
    AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, SolventClose,
    UnderwaterClose, MARGIN_SCALE,
};

Step 7: cargo check

cargo check -p openhl-liquidation が clean compile(テストはなし — scan がまだないので testable なものがない)。レッスン10 の 55 テストは依然 pass。

答え合わせ

cd ~/code/openhl-reference && git checkout 0a8464e
diff -u ~/code/my-openhl/crates/liquidation/src/scanner.rs ./crates/liquidation/src/scanner.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
git checkout main

scanner.rs は 0a8464e の accessor まで一致(scan メソッドとテストは レッスン12/13)。lib.rs は pub mod scanner; + pub use scanner::{...} について byte-for-byte 一致。

合格基準

cargo check -p openhl-liquidation が通る。account_equity 等への unused-import 警告は 想定どおり(レッスン12 用に staged、scan 本体が compile した瞬間に消える)— レッスン11 で警告 0 件なら逆におかしい。#[allow(unused_imports)] で抑えてもいいが、答え合わせは L11/L12 を一緒に ship するので抑えない。

まとめ(3行)

  • orchestration 層は独自の型語彙を持つ: CloseOutcomeKind(唯一の新 enum、下位層 2 出力を route)/ LiquidationRecord(per-account row)/ ScanReport(records vector + aggregate i64)。
  • ScanReport の aggregate(fund_deposits/fund_withdrawals/unfilled_deficit)は scan loop 中に事前計算(bridge の fold を省く、コスト 0)。unfilled_deficit が ADL への signal。
  • LiquidationScannerInsuranceFund を値で所有(Arc<Mutex>&mut も不要)— single mutator + 明確な shutdown point の state machine は自分の state を値で所有する。

次のレッスン(レッスン12)

orchestration の心臓 — scan メソッド — を実装。&[AccountSnapshot] + MarkPrice を取り、各アカウントを分類し、Liquidatable/Underwater を solvent/underwater outcome に dispatch、fund を in-place mutate、ScanReport を構築。最もシンプルな 4 つの unit test(empty / all-safe / atrisk / flat-skip)も。