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

レッスン10 — liquidation_fee + close-outcome decomposition — computeinsurance をつなぐ橋

問い

特定の 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_outcomeunderwater_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_outcomeunderwater_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::fromas でない、widening は無敗)。saturating_mulu64::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=-95001207-(-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, &params), 1_206);
    }

    #[test]
    fn fee_zero_notional() {
        let params = LiquidationParams::hyperliquid_default();
        assert_eq!(liquidation_fee(0, &params), 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, &params), 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, &params);
        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), &params);
        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), &params);
        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), &params);
        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), &params);
        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), &params);
        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), &params);
        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-liquidation55 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 ともに 260883bbyte-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_collectionfee_to_fund: 0> でなく >= と書いた)/ solvent_close_typical_liquidatabledebug_assert! panic(L4/L5 の account_equity/notional_value が誤符号 — 上流を先に修正)/ fee_saturates_* が overflow panic(n * bpsn.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(スキャナパート)に進む。