レッスン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 で起きたすべて)。各層が異なる問いに答えるので各層が独自の語彙を持つ。 CloseOutcomeKindはSolventClose/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。LiquidationScannerはInsuranceFundを直接所有(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 は InsuranceFund を Arc<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 型(新規フィールドなし = 純粋な語彙拡張)。classification は MarginHealth(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_withdrawals は amount の合計(支払われた もの、要求された ものでない — partial drain で違う)。unfilled_deficit は PartiallyDrained.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 fn。set_* なし(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。LiquidationScannerはInsuranceFundを値で所有(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)も。