Lesson 2 — MarginRatio + MarginHealth — the classification types the engine returns
Question
MarginRatio is a scaled i128 (basis points); MarginHealth is an enum classifying the position (Safe / AtRisk / Liquidatable / Underwater). The engine's return types.
Principle (minimum model)
MarginRatio(i128). Scaled by MARGIN_SCALE. Negative values are valid (Underwater positions).MarginHealthenum.Safe (ratio ≥ initial)/AtRisk (initial > ratio ≥ maintenance)/Liquidatable (ratio < maintenance, equity ≥ 0)/Underwater (equity < 0). Four variants.- Why classify? Each state has a different engine action (Safe = nothing; AtRisk = block new trades; Liquidatable = force-close; Underwater = insurance fund absorbs).
- Boundary semantics. ≥ at the upper bound; < at the lower. Avoids gaps and overlaps.
impl Display for MarginHealth. Human-readable for logging + diagnostics.- Type-safe transitions.
match health { ... }is exhaustive; compiler refuses unhandled variants. - Production parallel. Hyperliquid emits these classifications as oracle events for downstream consumers.
Worked example + steps
Lesson 2 — MarginRatio + MarginHealth — the classification types the engine returns
Goal
Concepts you'll grasp in this lesson:
- Why
MarginRatiois a newtype, not atypealias fori64— the newtype catches accidental "passed a raw i64 where a bps-scaled ratio was expected" bugs at compile time. Same discipline as funding'sMarkPrice(pub u64)vsu64. - Why
MarginHealthhas exactly 4 variants —Safe,AtRisk,Liquidatable,Underwater. Each variant authorizes a different engine action; collapsing any pair loses information the rest of the engine needs. - What each variant authorizes the rest of the engine to do — a quick decision matrix you can keep in your head.
- Why we don't derive
PartialOrd/Ordon the enum — even though the variants form a natural worsening order, ordered comparisons (health > Safe) read as code-smell next to explicitmatches!patterns.
Verification:
cargo build -p openhl-liquidation
…compiles.
Specific changes:
src/types.rs— appendsMARGIN_SCALE-typedMarginRationewtype andMarginHealthenum below the existingMARGIN_SCALEconstant andLiquidationParamsstruct. No changes to anything from Lesson 1.src/lib.rs— addsMarginRatioandMarginHealthto the existingpub use types::{...}re-export.
Lesson 2 still has no tests — MarginRatio and MarginHealth are passive data types. Lesson 3 finishes the types module with AccountSnapshot + CloseOrderSpec (also no tests). The first behavior test arrives at Lesson 4 with notional_value.
Recap
After Lesson 1:
- The crate has
MARGIN_SCALE(10⁴) andLiquidationParamswith ahyperliquid_default(). lib.rsre-exports both names fromtypes.cargo build -p openhl-liquidationpasses; one rustdoc warning aboutMarginHealth(still unresolved at this point).
Lesson 2 adds the two classification types the rest of the engine speaks in. From Lesson 4 onward, margin_ratio returns a MarginRatio and margin_health returns a MarginHealth.
Plan
Two edits, both small:
- Append to
crates/liquidation/src/types.rs—MarginRatio(pub i64)newtype withMARGIN_SCALE-relative docs, and theMarginHealthenum with 4 variants + per-variant doc comments explaining the authorization meaning of each. - Update
crates/liquidation/src/lib.rs— extend thepub use types::{...}line to include the two new names.
(Answer: 3 questions → 4 variants. Safe = yes to (a). AtRisk = no to (a), no to (b). Liquidatable = no to (a), yes to (b), yes to (c) (close-only suffices). Underwater = no to (a), yes to (b), no to (c) (insurance fund absorbs the deficit). A 3-variant enum (Safe/AtRisk/Liquidatable) would collapse Liquidatable and Underwater, losing the "does the insurance fund get involved?" signal. The engine doesn't have to recompute that — it's already encoded in the variant.)
Laying the four variants × the three actions they authorize into one matrix makes it immediately clear why four is the right number, and how each variant carries a "downstream decision" at the type level:
│ (a) Open new │ (b) Force-close │ (c) Does closing │
│ positions? │ the position? │ alone cover the │
│ │ │ deficit? │
─────────────────┼────────────────────┼───────────────────┼─────────────────────┤
Safe │ ✅ yes │ ❌ no │ N/A (no close) │
AtRisk │ ❌ no │ ❌ no │ N/A (no close) │
Liquidatable │ ❌ no │ ✅ yes │ ✅ yes (equity left) │
Underwater │ ❌ no │ ✅ yes │ ❌ no → insurance │
│ │ │ fund absorbs │
─────────────────┴────────────────────┴───────────────────┴─────────────────────┘
Downstream engine behavior (implemented in Lesson 7 / Module 3):
Safe ─► trader keeps operating
AtRisk ─► warn in UI, refuse new positions, let trader close voluntarily
Liquidatable ─► emit auto close order, deduct fee, return remaining equity
Underwater ─► emit auto close order, draw the deficit from the insurance fund
The point: each variant maps directly to its own set of authorized actions. Collapse Liquidatable and Underwater together and the "should we call the insurance fund?" signal disappears from the type — the engine then has to recompute equity to decide. Add more variants and no row produces a new column either (= these four are the minimal unique set of action profiles). "A state machine has exactly as many variants as the distinct downstream actions it triggers" — that's the principle this design embodies.
Walk-through
Step 1: Append to src/types.rs
Open crates/liquidation/src/types.rs. After the closing } of the LiquidationParams impl block, append:
/// Account margin ratio = `equity / notional`, scaled by [`MARGIN_SCALE`].
///
/// Sign: usually non-negative; can be negative when the account is
/// "underwater" — accumulated losses have driven equity below zero, and
/// liquidating the position alone cannot cover the deficit. The insurance
/// fund absorbs that shortfall (Stage 10b).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MarginRatio(pub i64);
/// Margin health classification given the account's current margin ratio
/// and the network's params. Four states, in decreasing health order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MarginHealth {
/// Margin ratio ≥ initial margin requirement. Healthy: the account
/// can open new positions or increase existing ones.
Safe,
/// Margin ratio ∈ [maintenance, initial). Allowed to hold existing
/// positions but not to add risk. Production UIs typically warn the
/// user.
AtRisk,
/// Margin ratio < maintenance, equity still ≥ 0. The engine should
/// liquidate the position at market; the account's remaining equity
/// (after the liquidation fee) returns to the account.
Liquidatable,
/// Margin ratio < 0 (equity is negative). Closing the position at
/// any price won't fully cover losses. The insurance fund absorbs
/// the shortfall — handled in Stage 10b.
Underwater,
}
Things to notice about these 25 lines:
-
MarginRatio(pub i64)is a newtype. Not atype MarginRatio = i64alias. The newtype gives the type checker a handle: a function that takesMarginRatiocannot be accidentally called with a rawi64value that's actually a balance, an account ID, or aMarkPrice. Thepub i64field means callers can construct one withMarginRatio(1000)and read it withratio.0— the type can't hold an invalid state in the first place (i.e., noi64value would be malformed), so there's no encapsulation invariant to defend, and we keep it as a transparent data container instead of hiding behind getters/setters. The "wrap aVecinsideMyVecto re-exposelen()" pattern is a cost paid to defend an invariant; don't pay it where no invariant exists. -
MarginRatioderives a lot of traits —Default,PartialOrd,Ord,Hash. The defaults aren't required by the engine, but they let downstream code (telemetry, sorted-by-worst-health scanners in Stage 10c, dashboards) useMarginRatiolike any other comparable value type.MarginRatio::default()isMarginRatio(0)— 0 bps, semantically "no ratio computed" or "freshly zeroed." The engine itself never readsdefault(); it always computes from a snapshot. -
MarginHealthdoes NOT derivePartialOrd/Ord. Even though the variants naturally order (Safe < AtRisk < Liquidatable < Underwater in worsening direction), ordered comparisons on enums read as code-smell.if health > MarginHealth::AtRiskis less clear thanif matches!(health, MarginHealth::Liquidatable | MarginHealth::Underwater). The compiler enforces the explicit pattern; future maintainers see exactly which variants the branch covers. Sloppy ordered comparisons on enums are a typical breeding ground for bugs (a code smell) — reach formatches!and explicit pattern matching first as a matter of discipline. When you really do need an order (sorting by severity in telemetry, for example), grow an explicitseverity_rank()method instead — that surfaces intent. -
Per-variant doc comments describe the authorization, not the math. "Margin ratio < maintenance" tells you when the variant fires, but the comment also says what the engine does in response ("should liquidate the position at market"). Doc comments here serve as the canonical reference for "what does Liquidatable actually mean to the rest of the system?"
-
Variant order matches worsening health. The variants are listed in the source in the order Safe → AtRisk → Liquidatable → Underwater. This isn't load-bearing for the compiler — Rust enums have no inherent order beyond what you derive — but it matches the order an exhaustive
matchtypically reads naturally (best case first, worst case last).
Step 2: Update src/lib.rs
Open crates/liquidation/src/lib.rs. Extend the pub use types::{...} line. Was:
pub use types::{LiquidationParams, MARGIN_SCALE};
Becomes:
pub use types::{LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE};
That's the entire lib.rs change — three new public names at the crate root, in alphabetical order. Constants traditionally sort last so MARGIN_SCALE stays at the end.
The rustdoc warning about [MarginHealth] (unresolved at Lesson 1) now resolves — the type exists.
Step 3: Compile
cargo build -p openhl-liquidation
Expected output:
Compiling openhl-liquidation v0.1.0 (/Users/.../my-openhl/crates/liquidation)
Finished `dev` profile [unoptimized + debuginfo] in 0.4s
Zero warnings. The Lesson 1 rustdoc warning about MarginHealth is gone.
Common errors:
error[E0432]: unresolved import 'crate::types::MarginRatio'— typo in thepub useline (e.g.,MarignRatio). Match the type names character-for-character.error: ambiguous re-export— you accidentally added a secondpub useline at the bottom instead of extending the existing one. Keep all re-exports on a singlepub use types::{...}block; the formatter expects this shape.
Design reflection
Three load-bearing decisions in this lesson:
-
MarginRatio(pub i64)newtype, nottype MarginRatio = i64. Aliases are zero-cost but also zero-safety: the compiler treats them as the same type. A newtype is also zero-cost at runtime (single-field structs lay out identical to the field) but creates a real distinction the compiler enforces. Use newtypes wherever the value carries a meaning beyond "an integer with this bit pattern." -
MarginHealthhas 4 variants because the engine makes 3 downstream decisions. Each variant maps cleanly to a unique combination of those 3 decisions. A 5th variant ("ImminentlyLiquidatable"? "RecentlyClosed"?) would require a 4th decision; until we have one, 4 is the right number. Match the cardinality of your enum to the cardinality of the actions it authorizes. -
No
PartialOrdonMarginHealth. The variants order naturally, but ordered comparisons on enums lose specificity (health > AtRiskdoesn't say which "worse than AtRisk" —LiquidatableorUnderwater?). Explicitmatches!patterns force every branch to spell out which variants it handles, andrustc -W non_exhaustive_omitted_patternscatches the case you forgot. Comparable enums are usually a code-smell; reach formatches!first.
Answer key
cd ~/code/openhl-reference
git checkout 22eedf9
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
After Lesson 2:
- types.rs matches lines 1 through ~
MarginHealth::Underwaterof Stage 10a's types.rs — theMARGIN_SCALE+LiquidationParams(from Lesson 1) plus the newMarginRatio+MarginHealth. The next two types (AccountSnapshot,CloseOrderSpec) are Lesson 3. - lib.rs matches Stage 10a's lib.rs except for
computemodule + 6 more re-exports — those come in Lessons 4–7.
Common questions
Q1: Why doesn't MarginRatio implement Display?
It could; the value is just an i64 in bps. We don't because no production code path formats a MarginRatio directly for end-user display — the bridge layer pulls .0 out and renders it with a known scale ("{}%", ratio.0 / 100). Adding Display invites callers to print MarginRatio in logs as a raw integer, which obscures the bps scale. Implement traits at the layer that needs them.
Q2: Could MarginHealth be a u8 and save memory?
Rust's enum layout for 4 variants without payloads already fits in a u8 — size_of::<MarginHealth>() == 1. The compiler picks the smallest discriminant. Switching to a raw u8 would lose the named variants, lose exhaustiveness checking in match, and gain nothing.
Q3: Should the variants carry payloads (e.g., AtRisk { headroom_bps: u32 })?
Tempting but premature. The downstream consumers (Stage 10c scanner, dashboards) re-derive what they need from the underlying margin_ratio. Variant payloads add construction overhead and complicate match ergonomics. Keep enums payload-free unless every consumer benefits from the payload.
Q4: Why include Underwater as a separate variant when Liquidatable could imply both "close + maybe absorb deficit"?
Because the bridge needs to do different things in the two cases. A Liquidatable account generates a single close order and the engine settles fee+remainder normally. An Underwater account generates a close order AND a credit-to-insurance-fund entry that the bridge must apply atomically. Separating the variants pushes the case distinction up to the type level, where exhaustive match catches it; merging them pushes the case distinction into runtime branching inside the bridge, where it's easier to miss. State machines benefit from variants that mirror the actions they trigger.
Q5: Should margin_health return Option<MarginHealth> for flat positions?
No — flat positions return MarginHealth::Safe (no notional, no margin requirement to fall short of). Option would force every caller to handle None explicitly, even though "flat = safe" is unambiguous. Don't add Option to encode states the type system already handles.
Next lesson (Lesson 3)
Lesson 3 closes the types module with AccountSnapshot (the input to every margin function) and CloseOrderSpec (the output the engine hands the bridge). After Lesson 3, the types module is complete; Lesson 4 starts the compute module with notional_value.
Summary (3 lines)
MarginRatio(i128)scaled bps; can be negative (Underwater).MarginHealthenum has 4 variants.- Each variant maps to engine action (Safe / AtRisk / Liquidatable / Underwater). Boundary: ≥ upper, < lower.
- Match-exhaustive; compiler enforces handling. Next: AccountSnapshot + CloseOrderSpec.