レッスン10 — liquidation_fee + close-outcome decomposition — compute と insurance をつなぐ橋
問い
特定の close に対して insurance fund に「いくら deposit / drain するか」を計算するものがまだない。liquidation event を fund movement とアカウント残額に分解し、scanner が deposit/withdraw_shortfall に流せる数字を生むには? そして solvent と underwater の 2 パスをどう分ける?
原理(最小モデル)
- すべての liquidation event は
(fund movement, account residual)のペアに分解できる。 solvent close は fund に credit + 正の residual を trader に。underwater close は fund に debit + 場合により partial fee。pure compute が credit/debit を生み、state machine が蓄積する。 debug_assert!を routing contract として使う。solvent_close_outcomeとunderwater_close_outcomeは非重複。それぞれが「もう一方 の呼び出しでなかった」を debug-assert で表明。caller が routing 義務を負う discriminated dispatch、関数は前提条件のウィンドウ内でのみ total。fee.saturating_sub(post_close_equity)が負値で magnitude 加算になる。i64 − (負の i64) = i64 + |負|。「already-underwater」が「partial fee」と同じ式を再利用できる(負値の減算が magnitude の加算だから)。ifの分岐が signed なら 1 式で両分岐をカバー。Resultでも 1 enum でもなく、2 つの異なる戻り型。SolventClose { fee_to_fund, residual_to_account }とUnderwaterClose { fee_to_fund, shortfall_to_fund }はfee_to_fundを共有するが、もう一方が完全に異なる意味(residual は trader へ 出る、shortfall は fund から 入る)。2 struct が 1 enum に勝つ。
具体例
per-close 分解:
SOLVENT: post_close_equity ≥ fee → SolventClose { fee_to_fund: +X, residual_to_account: +Y }
scanner: fund.deposit(fee_to_fund) / trader_balance += residual_to_account
UNDERWATER (2 サブケースを 1 shape で):
0 < post_close_equity < fee → UnderwaterClose { fee_to_fund: +X (partial), shortfall_to_fund: +Y }
post_close_equity ≤ 0 → UnderwaterClose { fee_to_fund: 0, shortfall_to_fund: +Z }
scanner: fund.deposit(fee_to_fund) (0 のことも) / fund.withdraw_shortfall(shortfall_to_fund) → WithdrawOutcome
SolventClose の出力はシステムから 出る(trader へ)、UnderwaterClose の出力はシステムへ 入る(fund から)— magnitude の shape は同じ、方向だけ逆。お金の 方向 は符号でなくフィールド名に住む。この分解が scanner を dumb なままにする。
失敗例(誤解)
「solvent_close_outcome と underwater_close_outcome を 1 関数にまとめて Result<SolventClose, UnderwaterClose> を返す」は誤り、問題 3 つ: (1) どちらの outcome もエラーでない(両方成功、別 state-machine 操作に route)。(2) scanner は margin health を すでに チェックして適切なほうを呼ぶ(Result dispatch は仕事を繰り返す)。(3) debug_assert! のペアは 2 関数のほうが意味を持つ(各関数が自分の契約を表明)。反対前提条件の 2 関数 > tagged union を返す 1 関数。
ここまでで「(fund movement, account residual) 分解」は着地した。ここで保険基金パートを閉じる(compute と insurance がカスケード数学を介して会話する)。コードは完全形。
🛑 予測。 1 BTC long、entry $100k、collateral $10k、$80,500 で force-close(損失 $19,500)。HL の
liquidation_fee_bps=150。fund は credit か debit か、金額は?(答え: debit、$10,707 を吸収。notional $80,500、fee = 80,500×150/10,000 = $1,207。realized PnL −$19,500、post_close_equity = 10,000 + (−19,500) = −$9,500 — fee 前に すでに underwater。fee を徴収できず、fund は「望ましかった fee」+「負 equity」を cover: $1,207 + $9,500 = $10,707。これが「already underwater」サブケース。)
ステップで組み立てる
Step 1: types.rs に SolventClose + UnderwaterClose
CloseOrderSpec の後に:
/// Solvent-close outcome (保険基金パート).
///
/// Produced by [`crate::compute::solvent_close_outcome`] for a Liquidatable
/// account whose post-close equity covers the liquidation fee in full.
/// Both fields are non-negative.
///
/// `fee_to_fund` is credited to the insurance fund; `residual_to_account`
/// is returned to the trader's collateral balance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SolventClose {
/// Fee deducted from collateral and credited to the insurance fund.
pub fee_to_fund: i64,
/// What's returned to the trader's collateral after the close + fee.
pub residual_to_account: i64,
}
/// Underwater-close outcome (保険基金パート).
///
/// Produced by [`crate::compute::underwater_close_outcome`] when the
/// account's post-close equity cannot cover the full liquidation fee.
///
/// Covers two sub-cases under one shape:
/// - Post-close equity is positive but smaller than the desired fee
/// (Liquidatable account whose close + fee turned underwater): the
/// remaining equity is paid as a partial fee, the uncollected portion
/// becomes the shortfall.
/// - Post-close equity is already negative (Underwater account): no fee
/// is collected, the full desired fee plus the negative equity becomes
/// the shortfall.
///
/// Both fields are non-negative; `fee_to_fund` may be `0` in the
/// negative-equity case.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UnderwaterClose {
/// Partial fee collected from any positive post-close equity, credited
/// to the insurance fund. May be `0`.
pub fee_to_fund: i64,
/// What the insurance fund must absorb so the close completes. The
/// caller hands this to [`crate::insurance::InsuranceFund::withdraw_shortfall`].
pub shortfall_to_fund: i64,
}
両フィールド i64(crate 型統一性)。doc はフィールドの 行き先 を名指す(出どころ でない — caller がどう使うかで名付ける)。UnderwaterClose はサブケースに関わらず shortfall_to_fund を常に運ぶ(caller は struct shape でなく 値 に match)。
Step 2: compute.rs に liquidation_fee
saturate_i128_to_i64 の後に:
/// Liquidation fee on a closed notional, in quote units.
///
/// `fee = notional × fee_bps / MARGIN_SCALE`, saturating on overflow.
/// Pure math — the caller (スキャナパート scanner / bridge) supplies the
/// actual fill notional from the matching engine.
///
/// Returns `0` for a zero notional (flat positions; should never reach
/// the engine but symbol-completeness pays off in proptest).
#[must_use]
pub fn liquidation_fee(closed_notional: u64, params: &LiquidationParams) -> i64 {
if closed_notional == 0 {
return 0;
}
let bps = i128::from(params.liquidation_fee_bps);
let n = i128::from(closed_notional);
let scaled = n.saturating_mul(bps);
let fee = scaled / i128::from(MARGIN_SCALE);
saturate_i128_to_i64(fee)
}
closed_notional: u64(入力、magnitude)→ i64(出力、crate 算術に揃える)。closed_notional == 0 fast-path。i128::from(as でない、widening は無敗)。saturating_mul(u64::MAX × u32::MAX ≈ 2^96 が i128 を超えうる)。除算は素の /(両オペランド非負、overflow しない)。
Step 3: compute.rs に solvent_close_outcome
/// Solvent-close outcome — the trader's collateral plus realized `PnL`
/// covers the liquidation fee in full, with positive residual returning
/// to the account.
///
/// **Precondition** (debug-asserted): the account is Liquidatable AND the
/// post-close equity (= collateral + realized `PnL` at `close_price`)
/// covers the desired fee. If the precondition is violated, the result
/// has `residual_to_account ≤ 0` — caller should have routed to
/// [`underwater_close_outcome`] instead.
///
/// Pure compute that produces the credit/debit pair for the caller
/// (スキャナパート scanner) to apply against [`crate::insurance::InsuranceFund`]
/// and the trader's balance.
#[must_use]
pub fn solvent_close_outcome(
snapshot: &AccountSnapshot,
close_price: MarkPrice,
params: &LiquidationParams,
) -> SolventClose {
let notional = notional_value(snapshot, close_price);
let fee = liquidation_fee(notional, params);
let post_close_equity = account_equity(snapshot, close_price);
debug_assert!(
post_close_equity >= fee,
"solvent_close_outcome called with post_close_equity={post_close_equity} < fee={fee}; \
caller should route to underwater_close_outcome instead",
);
SolventClose {
fee_to_fund: fee,
residual_to_account: post_close_equity.saturating_sub(fee),
}
}
計算パートの 3 関数を compose(notional_value/liquidation_fee/account_equity、新しい数学なし)。debug_assert! が契約そのもの(caller の routing 判断と等価、debug で発火・release でコンパイルアウト)。format-string capture(pass する限りゼロコスト)。saturating_sub(debug_assert のベルト&サスペンダー的補完 — release で上流バグが来ても clamp)。
Step 4: compute.rs に underwater_close_outcome
/// Underwater-close outcome — the account's post-close equity cannot
/// cover the liquidation fee, so the insurance fund must absorb the
/// shortfall.
///
/// Handles both sub-cases under one shape:
/// - Positive but insufficient post-close equity (Liquidatable account
/// whose close + fee turned underwater): the equity is paid as a
/// partial fee, the rest becomes the shortfall.
/// - Negative post-close equity (Underwater account): no fee is
/// collected, the entire fee plus `|equity|` becomes the shortfall.
///
/// **Precondition** (debug-asserted): `post_close_equity < fee_desired` —
/// otherwise the close is solvent and the caller should have routed to
/// [`solvent_close_outcome`].
#[must_use]
pub fn underwater_close_outcome(
snapshot: &AccountSnapshot,
close_price: MarkPrice,
params: &LiquidationParams,
) -> UnderwaterClose {
let notional = notional_value(snapshot, close_price);
let fee = liquidation_fee(notional, params);
let post_close_equity = account_equity(snapshot, close_price);
debug_assert!(
post_close_equity < fee,
"underwater_close_outcome called with post_close_equity={post_close_equity} ≥ fee={fee}; \
caller should route to solvent_close_outcome instead",
);
if post_close_equity > 0 {
// Partial fee: equity covers some but not all of the desired fee.
UnderwaterClose {
fee_to_fund: post_close_equity,
shortfall_to_fund: fee.saturating_sub(post_close_equity),
}
} else {
// Already underwater (equity ≤ 0). No fee collected; fund covers
// the full fee plus the negative equity. `fee - negative_equity`
// is `fee + |equity|` via saturating_sub semantics.
UnderwaterClose {
fee_to_fund: 0,
shortfall_to_fund: fee.saturating_sub(post_close_equity),
}
}
}
2 つのサブケース分岐が同じ shortfall_to_fund 式を共有(fee.saturating_sub(post_close_equity)、fee=1207, equity=-9500 → 1207-(-9500)=10707、.abs() も明示的 + も「符号で分岐」も書かずに到達 — 整数の「負値の減算」が「magnitude の加算」と等価)。if post_close_equity > 0 strict(equity=0 は else に落ち fee_to_fund=0)。debug_assert! の述語が solvent から反転(2 つの assertion が入力空間の非重複カバー、discriminated dispatch を証明)。
Step 5: 10 個の unit test
// ─── 保険基金パート: liquidation_fee ────────────────────────────────
#[test]
fn fee_basic() {
// 1.5% of $80,400 = $1,206 — matches the Perp Primer レッスン3 example.
let params = LiquidationParams::hyperliquid_default();
assert_eq!(liquidation_fee(80_400, ¶ms), 1_206);
}
#[test]
fn fee_zero_notional() {
let params = LiquidationParams::hyperliquid_default();
assert_eq!(liquidation_fee(0, ¶ms), 0);
}
#[test]
fn fee_zero_bps() {
// No fee if the network params zero it out.
let params = LiquidationParams {
initial_margin_bps: 1_000,
maintenance_margin_bps: 200,
liquidation_fee_bps: 0,
};
assert_eq!(liquidation_fee(1_000_000, ¶ms), 0);
}
#[test]
fn fee_saturates_on_pathological_input() {
// notional × bps would overflow i64 but saturates inside i128.
let params = LiquidationParams {
initial_margin_bps: 1_000,
maintenance_margin_bps: 200,
liquidation_fee_bps: u32::MAX,
};
let fee = liquidation_fee(u64::MAX, ¶ms);
assert_eq!(fee, i64::MAX);
}
// ─── 保険基金パート: solvent_close_outcome ──────────────────────────
#[test]
fn solvent_close_typical_liquidatable() {
// 1 BTC long, entry $100k, $10k collateral, close at $95k.
// notional = 95_000; fee = 95_000 × 150 / 10_000 = 1_425
// realized_pnl = (95_000 − 100_000) × 1 = −5_000
// post_close_equity = 10_000 − 5_000 = 5_000
// residual = 5_000 − 1_425 = 3_575
let s = snapshot(1, 100_000, 10_000);
let params = LiquidationParams::hyperliquid_default();
let outcome = solvent_close_outcome(&s, MarkPrice(95_000), ¶ms);
assert_eq!(outcome.fee_to_fund, 1_425);
assert_eq!(outcome.residual_to_account, 3_575);
}
#[test]
fn solvent_close_short_profit() {
// Short −1, entry $100k, $10k collateral, close at $90k (favorable!).
// notional = 1 × 90_000 = 90_000; fee = 1_350
// realized_pnl = (90_000 − 100_000) × (−1) = +10_000
// post_close_equity = 10_000 + 10_000 = 20_000
// residual = 20_000 − 1_350 = 18_650
let s = snapshot(-1, 100_000, 10_000);
let params = LiquidationParams::hyperliquid_default();
let outcome = solvent_close_outcome(&s, MarkPrice(90_000), ¶ms);
assert_eq!(outcome.fee_to_fund, 1_350);
assert_eq!(outcome.residual_to_account, 18_650);
}
#[test]
fn solvent_close_fee_consumes_all_residual() {
// Edge: post_close_equity exactly equals fee. residual = 0.
// size=1, entry=10_000, collateral=150, mark=10_000.
// notional = 10_000; fee = 150; pnl = 0; post_close_equity = 150
let s = snapshot(1, 10_000, 150);
let params = LiquidationParams::hyperliquid_default();
let outcome = solvent_close_outcome(&s, MarkPrice(10_000), ¶ms);
assert_eq!(outcome.fee_to_fund, 150);
assert_eq!(outcome.residual_to_account, 0);
}
// ─── 保険基金パート: underwater_close_outcome ────────────────────────
#[test]
fn underwater_close_already_underwater_pre_fee() {
// Perp Primer レッスン3 scenario: 1 BTC long, entry $100k, $10k collateral,
// close at $80,500. Realized PnL = −$19,500, post_close_equity = −$9,500.
// Notional = $80,500; fee = 1_207 (80_500 × 150 / 10_000)
// shortfall = fee − post_close_equity = 1_207 − (−9_500) = $10,707
let s = snapshot(1, 100_000, 10_000);
let params = LiquidationParams::hyperliquid_default();
let outcome = underwater_close_outcome(&s, MarkPrice(80_500), ¶ms);
assert_eq!(outcome.fee_to_fund, 0);
assert_eq!(outcome.shortfall_to_fund, 1_207 + 9_500);
}
#[test]
fn underwater_close_partial_fee_collection() {
// 1 BTC long, entry $100k, $10k collateral, close at $90,500.
// notional = $90,500; fee = 1_357 (90_500 × 150 / 10_000)
// realized_pnl = −$9,500; post_close_equity = $500
// post_close_equity (500) < fee (1357) → underwater branch
// fee_to_fund = 500 (partial fee from positive equity)
// shortfall = 1_357 − 500 = 857
let s = snapshot(1, 100_000, 10_000);
let params = LiquidationParams::hyperliquid_default();
let outcome = underwater_close_outcome(&s, MarkPrice(90_500), ¶ms);
assert_eq!(outcome.fee_to_fund, 500);
assert_eq!(outcome.shortfall_to_fund, 1_357 - 500);
}
#[test]
fn underwater_close_zero_equity_at_fee() {
// Edge: post_close_equity exactly 0 (collateral fully eaten by losses).
// 1 BTC long, entry $100k, $10k collateral, close at $90k → pnl = −10k,
// equity = 0. fee = 1_350. shortfall = full fee.
let s = snapshot(1, 100_000, 10_000);
let params = LiquidationParams::hyperliquid_default();
let outcome = underwater_close_outcome(&s, MarkPrice(90_000), ¶ms);
assert_eq!(outcome.fee_to_fund, 0);
assert_eq!(outcome.shortfall_to_fund, 1_350);
}
fee_basic は Perp Primer レッスン3 の数字(curriculum-to-implementation reinforcement)。fee_saturates_on_pathological_input が i128 saturation path を exercise する唯一のテスト。solvent_close_short_profit が long-loss の補完(符号付き入力は両符号をカバー)。underwater_close_already_underwater_pre_fee が Primer レッスン3 の数字を再利用($10,707)。
Step 6: lib.rs を更新
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 types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, SolventClose,
UnderwaterClose, MARGIN_SCALE,
};
Step 7: テスト実行
cargo test -p openhl-liquidation が 55 pass(compute 34 + insurance 21)。保険基金パート完成、crate 全体が 260883b に byte-for-byte 一致。
答え合わせ
cd ~/code/openhl-reference && git checkout 260883b
diff -u ~/code/my-openhl/crates/liquidation/src/compute.rs ./crates/liquidation/src/compute.rs
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
compute.rs / types.rs / lib.rs ともに 260883b と byte-for-byte 一致、insurance.rs はレッスン9 以来一致。保険基金パート完成。
合格基準
cargo test -p openhl-liquidation が 55 pass。よくあるエラー: underwater_close_already_underwater_pre_fee が負の shortfall(fee - post_close_equity の符号取り違え — fee.saturating_sub(post_close_equity) = 1207-(-9500)=+10707)/ underwater_close_partial_fee_collection が fee_to_fund: 0(> でなく >= と書いた)/ solvent_close_typical_liquidatable が debug_assert! panic(L4/L5 の account_equity/notional_value が誤符号 — 上流を先に修正)/ fee_saturates_* が overflow panic(n * bps を n.saturating_mul(bps) に)。
まとめ(3行)
(fund movement, account outcome)分解が cascade を composable に — scanner は solvent/underwater を判定して適切な outcome 関数を呼び credit/debit を route するだけ(数学と state のクリーンな分解で state-machine 層は dumb のまま)。debug_assert!は契約(dev で caller の routing bug を捕まえる)、saturating_subはシートベルト(release で上流バグを clamp)— 二段構えで dev と prod 両方をカバー。- 反対前提条件の 2 関数 > tagged union の 1 関数(caller が routing 判断済みなら)。
fee.saturating_sub(負の equity)が magnitude 加算になり 1 式で 2 サブケースを捌く。crate 全体が260883bに一致。
次のレッスン(レッスン11)— LiquidationScanner 導入(スキャナパート)
multi-account scanner が始まる。&[AccountSnapshot] を取り、各アカウントを margin_health で分類し、Liquidatable/Underwater に solvent_close_outcome/underwater_close_outcome を呼び、credit/debit を所有する InsuranceFund にスレッディングし、ScanReport を返す。SHA pin は 260883b から 0a8464e(スキャナパート)に進む。