レッスン6 — margin_health — 分類カスケードと境界セマンティクス
問い
MarginRatio を 4 つの MarginHealth variant にマップする。条件は ratio < 0、ratio < maintenance、ratio < 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 のアカウントはAtRisk(Liquidatableでない)。境界線そのものは より良い 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 を
Liquidatable(ratio < 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_ratio の i64::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_maintenance が Liquidatable で失敗(どこかで <→≤)/ health_underwater が Liquidatable で失敗(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 モジュールの完成をマークする。