Lesson 6 — margin_health — the classification cascade and boundary semantics
Question
margin_health(snapshot) -> MarginHealth classifies a position into the 4-variant enum. Cascade: Underwater (equity < 0) → Liquidatable (ratio < maintenance) → AtRisk (ratio < initial) → Safe.
Principle (minimum model)
- Cascade order. Check Underwater first (overrides everything); then Liquidatable; then AtRisk; default to Safe.
Underwater.equity < 0regardless of ratio. Catastrophic; insurance fund needed.Liquidatable.equity ≥ 0ANDratio < maintenance. Force-close, fees collected.AtRisk.ratio ≥ maintenanceANDratio < initial. Block new trades; allow shrinking + deposits.Safe.ratio ≥ initial. All operations allowed.- Boundary semantics matter. Off-by-one in the comparisons causes wrong classification. Tests cover every boundary (= and just-below-=).
- Conservation. Every snapshot classifies into exactly one variant. Match-exhaustive.
- Edge case: notional = 0. Defaults to Safe (no exposure → safe by definition). Documented; tested.
Worked example + steps
Lesson 6 — margin_health — the classification cascade and boundary semantics
Goal
Concepts you'll grasp in this lesson:
- Why the classification cascade checks
Underwaterfirst — a negative margin ratio is also less than maintenance, so flipping the order would silently reclassify underwater accounts as Liquidatable, losing the insurance-fund signal. Check the most extreme state first; let the cascade narrow inward. - Strict-less-than at every boundary —
ratio < maintenance_bps, not≤. An account at exactly maintenance isAtRisk, notLiquidatable. The line itself belongs to the better state; you only fall into the worse state when you're strictly below it. - Type widening for the params comparisons —
i64::from(params.initial_margin_bps)upcasts u32 to i64 at the boundary, then compares two i64 values. Avoids implicit casts at each comparison site. - Flat-as-Safe is free, not coded —
margin_ratioreturnsMarginRatio(i64::MAX)for flat positions, which compares ≥ any reasonableinitial_margin_bps, somargin_healthreturnsSafewithout a special-case branch. The composition handles it.
Verification:
cargo test -p openhl-liquidation
…passes 21 tests (16 from Lessons 4–5 + 5 new boundary tests).
Specific changes:
src/compute.rs— appendsmargin_healthaftermargin_ratio+ 5 unit tests inside the existing test module.src/lib.rs— extends the compute re-export to includemargin_health.
Lesson 6 is application: by now you've internalized the i128 / saturate / proptest discipline. The classification cascade is short — but the design hill (cascade order + strict-less-than) is where most bugs would hide in a careless implementation.
Recap
After Lesson 5:
compute.rshasnotional_value,unrealized_pnl,account_equity,margin_ratio, thesaturate_i128_to_i64helper, plus 13 unit tests and 3 proptests.- The non-monotonic edge case is encoded in
long_ratio_monotonic_in_mark_when_leveredwithprop_assume!. cargo testruns 16 tests, all green.
Lesson 6 maps MarginRatio values to MarginHealth variants. The function is short. The decisions are not.
Plan
Three edits:
- Append
margin_healthtocrates/liquidation/src/compute.rs— 13 lines plus the doc comment. Sits belowmargin_ratioand uses it. - Add 5 unit tests in the existing test module — one per
MarginHealthvariant (4 tests) plus one boundary test at the exact maintenance threshold. - Update
crates/liquidation/src/lib.rs— extend thepub use compute::{...}line.
(Answer: Underwater accounts get classified as Liquidatable. A ratio of −5_000 is < maintenance_bps (= 200), so the Liquidatable branch fires first and the cascade never reaches the Underwater check. Result: the bridge doesn't get the insurance-fund-needed signal, the underwater deficit silently routes through the regular liquidation path, and the position closes solvently in the books even though the math says it didn't. Cascade order is load-bearing — check the most extreme state first; each step inward narrows the remaining range.)
Laying the four-state cascade out on the margin-ratio number line makes it visible why this is the only order that works, and why reversing it would let Underwater get "absorbed" into Liquidatable:
(worsening ◄────────────────── value magnitude ──────────────────► improving)
margin ratio: ── −∞ ── 0 ─────── maintenance_bps ─────── initial_bps ─────── i64::MAX ──
↑ ↑ ↑ ↑ ↑
│ │ (e.g. 200) │ (e.g. 1000) │ │
│ │ │ │ │
└────┴──┐ ┌──────────────┴──┐ ┌─────────────────┴──┐ ┌──────────────┘
▼ ▼ ▼ ▼ ▼ ▼
🔴 Underwater 🟠 Liquidatable 🟡 AtRisk 🟢 Safe
(ratio < 0) (0 ≤ ratio (maint ≤ ratio (initial ≤ ratio;
< maintenance) < initial) flat lands here
via i64::MAX too)
🟢 Correct cascade order (narrow inward):
① if ratio < 0 ──► Underwater (cut out the most extreme region first)
② else if ratio < maintenance ──► Liquidatable (Underwater already excluded in ①)
③ else if ratio < initial ──► AtRisk (Liquidatable already excluded in ②)
④ else ──► Safe (the whole remaining region)
※ Each branch operates only on "what survived being filtered by the branches above."
🔴 Reversed (check the wide region first):
① if ratio < maintenance ──► Liquidatable ← ratio = -5_000 (Underwater) also
satisfies < 200, so it gets
"absorbed" into Liquidatable
② if ratio < 0 ──► Underwater ← unreachable
③ ...
Result: the insurance-fund signal disappears; the Underwater deficit flows silently
through the normal close path. The math says the deficit wasn't resolved,
but the books record it as a solvent close.
The point: when the cascade is written as "carve out the most extreme region first, then narrow," each branch's condition naturally operates inside the complement of every prior branch. Reverse it — check the wide region first — and the more-extreme region (Underwater) gets swallowed by the wider one (Liquidatable), degrading what should be a four-way classification into three. Lesson 7's close_order_spec keys off these four states to decide what to emit, so collapsing the narrowing breaks the downstream behaviour entirely.
Walk-through
Step 1: Append margin_health to src/compute.rs
Open crates/liquidation/src/compute.rs. After margin_ratio, before the #[cfg(test)] block, append:
/// 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
}
}
Five things to notice about this 18-line function:
-
The cascade order checks
Underwaterfirst. A negative ratio satisfies< maintenance_bpstoo — so if Liquidatable were checked first, every Underwater account would be misclassified as Liquidatable. The invariant: each branch's condition excludes everything the previous branches caught. Underwater (< 0) is the strictest, narrowing inward through Liquidatable (< maintenance), AtRisk (< initial), and finally Safe (the residual). -
<, not≤, at every threshold. An account whose ratio equalsmaintenance_bpsis not yet Liquidatable — it's AtRisk. The conventional reading: maintenance margin is the line you have to stay above; you cross it (strictly) before getting liquidated. The doc spells this out; the test in Step 2 enforces it. Strict inequality means the threshold value itself belongs to the better-health state. -
i64::from(params.initial_margin_bps)widens u32 → i64. The fields areu32(saves memory, plenty of range for bps values up to ~4 billion). The ratio isi64(the type forced by signed division inmargin_ratio). Comparing different integer types is a compile error in Rust; widening at the boundary keeps the comparisons clean. One cast per param at the top; the cascade body reads as pure i64 < i64. -
No special case for flat positions.
margin_ratioreturnsMarginRatio(i64::MAX)for a flat account.i64::MAXis far above any saneinitial_margin_bps, so the cascade falls through toSafe. The flat-as-Safe property is encoded bymargin_ratio's flat-position guard —margin_healthdoesn't need to know about it. This is function composition at work: downstream functions inherit invariants established upstream, automatically.margin_ratiodecides "flat →i64::MAX" in one spot, and every downstream consumer (thismargin_health, Lesson 7'sclose_order_spec) gets "flat = always lands in Safe" for free — zero extra code. If you have the habit of "adding a flag-branch inside every function for every edge case," this is the paradigm shift worth internalizing: scope each invariant to a single owner, then trust it downstream. A future tweak to flat-position semantics happens in one place (margin_ratio), not in two synchronized branches. -
Function takes
&LiquidationParams, notLiquidationParamsby value. Even thoughLiquidationParamsisCopy(12 bytes), the reference signature signals "I'm reading these, not consuming them." The bridge passes the sameparamsto everymargin_healthcall for an entire scan; reference avoids a (technically free) move per call.
Step 2: Add 5 boundary tests
Inside the existing #[cfg(test)] mod tests { ... }, after the margin_ratio unit tests (and before the proptest! block), add:
// ─── 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);
}
Four things to notice:
-
Each test names the arithmetic that produces the test's
MarginHealth. "Ratio 1_500 bps (= 15%)" tells the reader (and a future-you reading a failure) exactly what range the test exercises. A test with the comment but the wrong setup is easier to spot than a bare assertion. -
Four tests for the four variants, one for the boundary. Each cascade branch gets a positive test;
health_boundary_at_maintenanceproves the strict-less-than convention. Without that fifth test, a future refactor that flipped<to≤would pass the other four but silently change behavior at the exact threshold — which is the most common margin level for production positions (accounts get to maintenance before they get below). -
health_boundary_at_maintenanceconstructs its own params, nothyperliquid_default(). The hyperliquid default hasliquidation_fee_bps = 150, irrelevant to this test, and the explicit struct construction documents which fields the test actually depends on. Other tests use the default because the fee field isn't load-bearing for them. -
MarginHealth::Underwateris exercised via the Lesson 5 underwater case (mark = 50against a long position with thin collateral). Same setup asratio_can_be_negativefrom Lesson 5 — the negative-ratio test proved the math; the variant test proves the classification.
Step 3: Update src/lib.rs
Open crates/liquidation/src/lib.rs. Extend the compute re-export. Was:
pub use compute::{account_equity, margin_ratio, notional_value, unrealized_pnl};
Becomes:
pub use compute::{
account_equity, margin_health, margin_ratio, notional_value, unrealized_pnl,
};
One new name — margin_health — alphabetically inserted between account_equity and margin_ratio. The line now wraps once it crosses ~5 items.
Step 4: Run the tests
cargo test -p openhl-liquidation
Expected output:
running 21 tests
test compute::tests::equity_can_go_negative ... ok
test compute::tests::equity_collateral_plus_pnl ... ok
test compute::tests::health_at_risk ... ok
test compute::tests::health_boundary_at_maintenance ... ok
test compute::tests::health_liquidatable ... ok
test compute::tests::health_safe ... ok
test compute::tests::health_underwater ... ok
test compute::tests::notional_flat_is_zero ... ok
test compute::tests::notional_long ... ok
test compute::tests::notional_short_uses_abs ... ok
test compute::tests::pnl_flat_is_zero ... ok
test compute::tests::pnl_long_loss ... ok
test compute::tests::pnl_long_profit ... ok
test compute::tests::pnl_short_loss ... ok
test compute::tests::pnl_short_profit ... ok
test compute::tests::ratio_can_be_negative ... ok
test compute::tests::ratio_exactly_ten_percent ... ok
test compute::tests::ratio_flat_returns_max ... ok
test compute::tests::long_ratio_monotonic_in_mark_when_levered ... ok
test compute::tests::margin_ratio_deterministic ... ok
test compute::tests::short_ratio_monotonic_in_mark ... ok
test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Common errors:
health_boundary_at_maintenancefails withLiquidatableinstead ofAtRisk— you accidentally used≤instead of<somewhere in the cascade. The boundary test exists to catch exactly this.health_underwaterfails withLiquidatable— you put theUnderwatercheck after theLiquidatablecheck. Reorder; the most extreme state goes first.
Design reflection
Three load-bearing decisions in this lesson:
-
Cascade order: check the most extreme state first.
UnderwaterbeforeLiquidatablebeforeAtRiskbeforeSafe. The narrowing direction means each branch's condition excludes everything earlier branches caught; reversing the order silently routes severe cases through milder branches. When cascade conditions overlap, sort from strictest to loosest. -
Strict-less-than at thresholds: the line belongs to the better state. An account exactly at maintenance is
AtRisk, notLiquidatable. This is a convention call — production exchanges differ — but consistency within a system matters more than which side the threshold belongs to. Pick a convention, name it in the doc, enforce it with a boundary test. -
No special case for flat positions in
margin_health. Composition withmargin_ratio(which returnsi64::MAXfor flat) makes the property fall out for free. Addingif snapshot.position_size.0 == 0 { return Safe; }would duplicate the flat-position behavior in two places — and would drift the moment one of them changed. Encode invariants in one place; let downstream functions inherit them by composition.
Answer key
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
After Lesson 6:
- compute.rs matches Stage 10a through
margin_health+ 18 unit tests + 3 proptests. The last function (close_order_spec) and its 3 tests are Lesson 7. - lib.rs has 5 of 6 compute re-exports. The final one (
close_order_spec) lands in Lesson 7.
Common questions
Q1: Why not return Result<MarginHealth, ...> for cases like a misconfigured params (maintenance ≥ initial)?
The function is total — every input produces a defined output. A misconfigured params (maintenance == initial, or maintenance > initial) still classifies every account into one of the four variants, just with the wrong semantics. Returning Result would force every call site to handle a MisconfiguredParams error that never arises from a bridge that constructed params validly. Total functions are overwhelmingly easier to compose; complete the parameter-validity check at the system input boundary (config load / config parse), and the downstream domain layer (margin_health and the other classifiers) treats invariants as fully held — this is the "Parse, don't validate" discipline: concentrate validation logic at the boundary, then build the domain layer out of total functions.
Q2: Could margin_health use a sorted-thresholds array and binary-search to be more "data-driven"?
With four states the explicit cascade is clearer and faster. Binary search wins when the number of thresholds grows past ~10 — at that point you'd refactor. Premature generalization here adds machinery the engine doesn't need. Optimize for the cardinality you have, not the cardinality you might have someday.
Q3: What happens if maintenance_bps > initial_bps (misconfigured)?
The cascade still produces a defined classification: at ratio >= maintenance_bps, the next branch is ratio < initial_bps (which is false, since maintenance > initial means ratio also ≥ initial), so we fall through to Safe. At ratio ∈ [0, maintenance_bps), we land on Liquidatable. AtRisk becomes unreachable. Misconfigured params produce a coherent but unintended classification scheme; the validation belongs at param construction, not in the classifier.
Q4: Why doesn't margin_health cache the i64 conversions of params?
Because callers typically invoke margin_health once per account in a per-block sweep, and the bridge passes the same &LiquidationParams to every call. The two i64::from(u32) casts are zero-cost — the compiler emits a mov instruction at most. Cache only when you've measured the cost; don't reach for it as a reflex.
Q5: Could the cascade use match with range patterns (0..maintenance_bps => Liquidatable)?
Rust's match does support exclusive-range patterns (since 1.26), so syntactically yes. But the patterns would be i64::MIN..0, 0..maintenance_bps, maintenance_bps..initial_bps, initial_bps..=i64::MAX. The need for named boundaries (referring to variables, not literals) means each pattern requires a guard clause anyway. The if/else cascade reads cleaner here. Use match for structural cases; use if/else for inequality cascades on the same value.
Next lesson (Lesson 7)
Lesson 7 closes Stage 10a with close_order_spec — the function that turns a snapshot into the CloseOrderSpec the bridge consumes. Three unit tests for long-closes-with-Sell, short-closes-with-Buy, and the flat-position edge case (qty = 0). Shorter than Lesson 6 — by Lesson 7 you have the full compute module behind you, and the lesson is mostly the bridge between Lesson 4's unsigned_abs discipline and the engine's outward-facing interface.
Summary (3 lines)
margin_healthcascade: Underwater → Liquidatable → AtRisk → Safe. Each variant corresponds to engine action.- Boundary semantics tested (every = and just-below-=). Match-exhaustive; off-by-ones impossible.
- Edge case: notional = 0 → Safe. Documented and tested. Next: close_order_spec.