FABRKNT
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
純粋な compute
レッスン 7 / 14·CONTENT30 分60 XP
コース
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
レッスンの役割
CONTENT
順序
7 / 14

レッスン6 — margin_health — 分類カスケードと境界セマンティクス

問い

MarginRatio を 4 つの MarginHealth variant にマップする。条件は ratio < 0ratio < maintenanceratio < initial、それ以外。だが負の ratio は maintenance より 小さい。cascade をどの順で書く? 境界(ratio がちょうど maintenance)はどちらの state に属する?

原理(最小モデル)

  • cascade は Underwater を最初に check する。 負の ratio は maintenance より 小さいので、順序を逆にすると underwater が静かに Liquidatable に再分類され、insurance-fund シグナルが失われる。最も極端な state から先に check し、内側に narrow する。
  • すべての境界で strict-less-than。 ratio < maintenance_bps でない)。ちょうど maintenance のアカウントは AtRiskLiquidatable でない)。境界線そのものは より良い state に属する。
  • params 比較のための型 widening。 i64::from(params.initial_margin_bps) で u32→i64、その後 i64 同士の比較。各サイトの暗黙キャストを避ける。
  • Flat-as-Safe は無償、明示的に書かない。 margin_ratio が flat に i64::MAX を返し、それは妥当な initial_bps より大きいので、cascade は special-case なしに Safe に fall through。composition が片付ける。

具体例

4 状態の判定 cascade を margin ratio の数直線上に:

   margin ratio:  ── −∞ ── 0 ─── maintenance_bps(200) ─── initial_bps(1000) ─── i64::MAX ──
                  🔴 Underwater    🟠 Liquidatable          🟡 AtRisk            🟢 Safe
                  (ratio < 0)      (0 ≤ ratio < maint)      (maint ≤ ratio       (initial ≤ ratio、
                                                            < initial)            flat の i64::MAX もここ)

   🟢 正しい cascade (内側に narrow):
      ① ratio < 0          → Underwater   ② ratio < maintenance → Liquidatable
      ③ ratio < initial    → AtRisk        ④ else                → Safe
   🔴 逆順 (広い領域を先に check):
      ① ratio < maintenance → Liquidatable  ← ratio = −5_000 (Underwater) も < 200 で「吸い込まれる」
      → insurance-fund シグナルが消え、Underwater の不足が通常 close path で silent に流れる

cascade を「最も極端な領域から narrowing」として書くと、各分岐の条件は自然に上の分岐の補集合の中だけで成立する。

失敗例(誤解)

「cascade を Liquidatable → Underwater → ... の順(Liquidatable を先に)に書く」は誤り — ratio −5_000< maintenance(200) も満たすので Liquidatable 分岐が先に発火し、Underwater check に到達しない。bridge は insurance-fund-needed シグナルを受け取らず、underwater な不足が通常の liquidation path を silent に通る(数学は「不足を解消できない」と言っているのに帳簿上は solvent に close)。


ここまでで「cascade 順 = 極端から先」は着地した。ここから分類関数を足す(規律は内面化済み、これは応用編)。コードは完全形。

🛑 予測。 cascade を Liquidatableratio < maintenance)を最初に check する順で書いたら何が起きる?(答え: Underwater アカウントが Liquidatable に分類される。ratio −5_000< maintenance(200) でもあるので Liquidatable 分岐が先に発火し、Underwater check に到達しない。bridge は insurance-fund シグナルを受け取らず、不足が silent に通常 path を通る。cascade 順は load-bearing — 最も極端な state から先に check し、各ステップが残りの範囲を narrow する。)

ステップで組み立てる

Step 1: margin_health を追記

/// Classify margin health against the given params.
///
/// Returns one of four states in decreasing health order:
/// `Safe → AtRisk → Liquidatable → Underwater`. The boundaries use strict
/// inequality below the threshold (`<`), so an account at exactly the
/// maintenance ratio is `AtRisk`, not `Liquidatable`. This matches the
/// conventional "you start liquidating when you fall below the line"
/// reading.
#[must_use]
pub fn margin_health(
    snapshot: &AccountSnapshot,
    mark: MarkPrice,
    params: &LiquidationParams,
) -> MarginHealth {
    let ratio = margin_ratio(snapshot, mark);
    let initial_bps = i64::from(params.initial_margin_bps);
    let maintenance_bps = i64::from(params.maintenance_margin_bps);

    if ratio.0 < 0 {
        MarginHealth::Underwater
    } else if ratio.0 < maintenance_bps {
        MarginHealth::Liquidatable
    } else if ratio.0 < initial_bps {
        MarginHealth::AtRisk
    } else {
        MarginHealth::Safe
    }
}

cascade 順は Underwater 最初(各分岐の条件は前の分岐が捕まえたものを排除)。しきい値は全て <(境界線はより良い state に属する)。i64::from(...) で u32→i64 widening(params ごと 1 回、本体は i64<i64 として読める)。flat の special case なし(margin_ratioi64::MAX で composition により無償に Safe に — 不変量を 1 箇所に閉じ込め下流は信頼する)。&LiquidationParams(読むだけ、consume しない)。

Step 2: 境界テストを 5 個追加

    // ─── margin_health ─────────────────────────────────────────────

    #[test]
    fn health_safe() {
        // Ratio 1_500 bps (= 15%) with params (initial = 1_000, maintenance = 200) → Safe
        let s = snapshot(10, 100, 150);
        let p = LiquidationParams::hyperliquid_default();
        assert_eq!(margin_health(&s, MarkPrice(100), &p), MarginHealth::Safe);
    }

    #[test]
    fn health_at_risk() {
        // Ratio 500 bps with params (initial = 1_000, maintenance = 200) → AtRisk
        let s = snapshot(10, 100, 50);
        let p = LiquidationParams::hyperliquid_default();
        assert_eq!(margin_health(&s, MarkPrice(100), &p), MarginHealth::AtRisk);
    }

    #[test]
    fn health_liquidatable() {
        // Ratio 100 bps (= 1%) with params (maintenance = 200) → Liquidatable
        let s = snapshot(10, 100, 10);
        let p = LiquidationParams::hyperliquid_default();
        assert_eq!(
            margin_health(&s, MarkPrice(100), &p),
            MarginHealth::Liquidatable
        );
    }

    #[test]
    fn health_underwater() {
        // Equity goes negative (mark moved hard against long): Underwater
        let s = snapshot(10, 100, 100);
        let p = LiquidationParams::hyperliquid_default();
        assert_eq!(margin_health(&s, MarkPrice(50), &p), MarginHealth::Underwater);
    }

    #[test]
    fn health_boundary_at_maintenance() {
        // Ratio exactly == maintenance_bps → AtRisk (strict `<` for Liquidatable)
        let p = LiquidationParams {
            initial_margin_bps: 1_000,
            maintenance_margin_bps: 200,
            liquidation_fee_bps: 0,
        };
        // notional = 1_000, equity = 20 → ratio = 200 bps exactly
        let s = snapshot(10, 100, 20);
        assert_eq!(margin_health(&s, MarkPrice(100), &p), MarginHealth::AtRisk);
    }

variant ごと 4 + 境界 1。health_boundary_at_maintenance がないと < リファクタが他 4 テストを pass したまま通る(本番ポジションの margin level はちょうどこの辺に集まる)。境界テストは独自 params を組み立てる(依存フィールドを文書化)。

Step 3: lib.rs を更新

pub use compute::{
    account_equity, margin_health, margin_ratio, notional_value, unrealized_pnl,
};

Step 4: テスト実行

cargo test -p openhl-liquidation が 21 pass(L4-5 の 16 + 境界 5)。

答え合わせ

cd ~/code/openhl-reference && git checkout 22eedf9
diff -u ~/code/my-openhl/crates/liquidation/src/compute.rs ./crates/liquidation/src/compute.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
git checkout main

compute.rs は margin_health + 18 unit test + 3 proptest まで一致(close_order_spec は L7)。lib.rs は compute re-export 6 個中 5 個。

合格基準

cargo test -p openhl-liquidation が 21 pass。よくあるエラー: health_boundary_at_maintenanceLiquidatable で失敗(どこかで <)/ health_underwaterLiquidatable で失敗(Underwater check が Liquidatable の後 — 並び替え)。

まとめ(3行)

  • cascade 順は最も極端な state を先に(Underwater → Liquidatable → AtRisk → Safe)— narrowing 方向に並べ、各分岐が前の分岐を排除。逆順だと深刻なケースが緩いケースの分岐を通り抜ける。
  • しきい値で strict-less-than(境界線はより良い state に属する)— 慣例を選び doc で名指し境界テストで強制。
  • flat の special case を書かない — margin_ratio(flat に i64::MAX)との composition で無償に Safe。不変量は 1 箇所で表現し下流が継承する。

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

close_order_spec で compute を閉じる(snapshot を bridge が consume する CloseOrderSpec に変換)。long→Sell / short→Buy / flat→qty 0 の 3 テスト。side-inversion ルールをロックし、pure-compute モジュールの完成をマークする。