レッスン3 — AccountSnapshot + CloseOrderSpec — エンジンの入出力型
問い
liquidation はアカウントごとに unrealized PnL((mark - entry) * size)と equity(collateral + pnl)を計算する。だが funding::Position は (account, size) しか持たない。エンジンの入力型はどう設計し、出力(close order)には何を持たせるべきか?
原理(最小モデル)
- liquidation は
funding::Positionを再利用せず独自AccountSnapshotを定義する。Positionは(account, size)、liquidation は(account, size, avg_entry, collateral)が要る。2 crate・2 snapshot 型・cross-coupling なし。bridge がそれぞれを自分の台帳から組み立てる。 - funding と共有する「snapshot」の規律。 エンジンは呼び出し側が組み立てた snapshot を consume、可変 state を所有しない。proptest が determinism バグを捕まえられるのはこの I/O-free な純粋さゆえ。
CloseOrderSpecに price フィールドを持たせない。 liquidation は常に market で close(エンジンは価格を選ばない)。bridge がclob::Action::SubmitMarketに変換し、板の次の価格で約定。Side/Qtyを liquidation-local の新型でなくopenhl_clobから借りる。 matching engine が話すのと同じ概念。並行するSideenum を 2 つ置くと drift する翻訳サーフェスが生まれる。
具体例
types モジュールが確定する入出力 = エンジンと外界の唯一の接触面:
[上流: bridge/clearing (台帳の所有者)] ─tick ごとに snapshot 構築─►
入力: AccountSnapshot { account, position_size, avg_entry, collateral } (不変・read-only・Copy)
│
★ liquidation エンジン (L4 notional/pnl → L5 equity/ratio → L6 health → L7 close_order_spec)
│
出力: CloseOrderSpec { account, side, qty } (price なし=market、Liquidatable/Underwater のみ emit)
│ + セクション3-4 で InsuranceFundDelta も並行 emit
[下流: bridge → matching engine (CLOB)]
(a) 2 つの型がエンジンと外界の唯一の接触面、(b) 入力 snapshot も出力 spec も不変 — エンジンは台帳を更新しない(所有権は bridge に残る)。レッスン0の「リスク計算専用の不変 snapshot 型を分離して依存をクリーンに保つ」の具体形。
失敗例(誤解)
「AccountSnapshot を openhl-funding 側に置いて両 crate で共有」は誤り — funding は avg_entry も collateral も必要としない。funding::Position に足すと funding snapshot が無駄に膨らみ、bridge は funding が無視するフィールドにまで値を入れる羽目に。2 crate・2 snapshot 型が正しい(bridge が正典台帳を持ち、tick ごとに 2 つの view を生成するコストは安い)。
ここまでで「2 crate・2 snapshot 型」は着地した。ここから 2 つの I/O 型を足して types モジュールを閉じる(受動的データ型、テストはレッスン4)。コードは完全形。
🛑 予測。 unrealized PnL の式
(mark - entry) * sizeで、funding::Positionから得られない入力は何か、なぜ funding では不要だったか?(答え:avg_entry(PnL 項)とcollateral(equity)。funding の式size * mark * rateに entry 係数はなく、collateral も読まない(funding の settlement delta は bridge で balance に適用、台帳管理は bridge に閉じる)。liquidation の仕事はcollateral + pnlがしきい値を下回ったかを 測る ことなので両方が要る。仕事が違えば snapshot も違う。)
ステップで組み立てる
Step 1: AccountSnapshot を追記
MarginHealth の後に:
/// Snapshot of one account's perpetual-market state, assembled by the
/// bridge layer before invoking the liquidation engine. Same "snapshot"
/// model as `openhl_funding::Position`: the engine treats this as a
/// per-tick read-only view, never mutates it.
///
/// `avg_entry` is the volume-weighted average price at which the account
/// opened its current net position. The owning layer (vault / clearing)
/// is responsible for maintaining this across fills.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AccountSnapshot {
pub account: AccountId,
pub position_size: PositionSize,
pub avg_entry: MarkPrice,
pub collateral: Notional,
}
avg_entry は MarkPrice 型で持つ(entry 価格と mark は同じ unit-of-account、別 EntryPrice 型は全 PnL サイトで変換を要して利益ゼロ)。collateral: Notional は signed(account_equity = collateral + unrealized_pnl を signed sum のまま流す — unsigned だと全 equity サイトで as i64 キャスト。レッスン4 の符号トリックはこの「計算経路を全部 signed で統一」前提の上に成り立つ)。doc コメントが呼び出し側の契約(owning layer が avg_entry を維持)を明示。
Step 2: CloseOrderSpec を追記
/// Specification for a single liquidation close order, generated by the
/// engine and consumed by the bridge layer. The bridge encodes this as
/// `openhl_clob::Action::SubmitMarket` and routes it through the matching
/// engine.
///
/// Always a market order — liquidation accepts any available price.
/// Always the opposite side of the position: a long position closes via
/// `Side::Sell`, a short via `Side::Buy`. Quantity is the absolute value
/// of the position size.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CloseOrderSpec {
pub account: AccountId,
pub side: Side,
pub qty: Qty,
}
price フィールドなし(liquidation は価格を選ばず market、matching engine が板の深さで約定)。side: Side / qty: Qty は openhl_clob を再利用(並行 Side enum を 2 つ置くと変換レイヤーが drift の温床に — 1 enum・1 真実)。PositionSize(i64)→Qty(u64) の変換(unsigned_abs())はレッスン7 で行う。
Step 3: src/lib.rs を更新
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
Step 4: コンパイル
cargo build -p openhl-liquidation が warning も error もゼロ。types モジュール完成。
答え合わせ
cd ~/code/openhl-reference && git checkout 22eedf9
diff -u ~/code/my-openhl/crates/liquidation/src/types.rs ./crates/liquidation/src/types.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
git checkout main
types.rs は 22eedf9 と byte-for-byte 完全一致(セクション1 完了)。lib.rs はまだ pub mod compute; と compute 系 re-export が揃わない(レッスン4〜7)。
合格基準
cargo build -p openhl-liquidation が warning ゼロ。よくあるエラー: E0432(Qty import 欠け — types.rs 冒頭が use openhl_clob::{AccountId, Qty, Side}; + use openhl_funding::{MarkPrice, Notional, PositionSize}; のままか確認)/ Notional 未解決(同 import 行)。
まとめ(3行)
AccountSnapshot { account, position_size, avg_entry, collateral }は liquidation-local(funding と共有しない — 仕事が違えば snapshot も違う)。不変・read-only・Copy で、エンジンは台帳を所有しない。CloseOrderSpec { account, side, qty }は price なし(liquidation は market、価格選択は bridge 下のレイヤー)。Side/Qtyはopenhl_clobを借りる(境界の語彙は共有、内部型だけ特殊化)。collateralを signedNotionalにして計算経路を全部 signed で統一(境界で変換、キャスト漏れの静かなバグを型不一致として根絶)。types モジュールは22eedf9と完全一致。
次のレッスン(レッスン4)
compute モジュール開始。notional_value + unrealized_pnl が crate 最初の挙動テストを呼び込む。long/short どちらでも符号が正しく揃う signed-multiplication のトリックと、i64 overflow を i128 中間値で防ぐ規律を見る。