Lesson 4 — notional_value + unrealized_pnl — the signed-multiplication trick
Question
notional_value(size, mark) = |size| * mark + unrealized_pnl(size, entry, mark) = (mark - entry) * size. The signed-multiplication trick on size does the right thing for both long and short. Pure compute; two lines each.
Principle (minimum model)
notional_valueis unsigned magnitude.|size| * mark. Long or short doesn't matter; just dollar exposure.unrealized_pnluses signed size.(mark - entry) * size. Long size > 0: gain if mark > entry. Short size < 0: gain if mark < entry. Signed multiplication makes both directions correct.- Why this is elegant. Two formulas; both work for both sides; no
if long { ... } else { ... }branch. Less code = fewer bugs. - Saturating arithmetic. Both
*operations saturate on overflow. Consensus-safe. - Type-correct.
Size * Price = Notional;(Price - Price) * Size = Pnl. Compiler-checked. - Tests. (1) Long profit. (2) Long loss. (3) Short profit. (4) Short loss. (5) Flat (size = 0).
- Reusable. Used by
equity+margin_ratio(next lesson) + every downstream metric.
Worked example + steps
Lesson 4 — notional_value + unrealized_pnl — the signed-multiplication trick
Goal
Concepts you'll grasp in this lesson:
- Why
notional_valuereturnsu64andunrealized_pnlreturnsi64— notional exposure is always non-negative (|size| × mark); PnL is signed (mark − entrycan be either side). Reflecting each at the return-type level lets the compiler catch sign-confusion bugs at call sites. unsigned_abs()overabs()fori64—i64::MIN.abs()overflows (positivei64::MINdoesn't exist).unsigned_abs()returnsu64and never panics. Use it whenever you need the magnitude of a signed integer.- The signed-multiplication trick that handles long vs short with no branching —
(mark − entry) × size, withsizesigned, produces the right sign for both directions naturally. Four sign combinations resolve to four correct PnL values with noif side == Longanywhere. - The i128-intermediate discipline — sign-preserving subtraction (
i128::from(mark.0) − i128::from(entry.0)) followed by an overflow-safe product, saturated back toi64. Same shape as funding'scompute_premium. saturate_i128_to_i64as a load-bearing helper — any product that can exceedi64::MAXat network-pathological inputs will, eventually. Saturate, don't panic.
Verification:
cargo test -p openhl-liquidation
…passes 8 tests (3 for notional_value, 5 for unrealized_pnl).
Specific changes:
- Create
crates/liquidation/src/compute.rs— the file doesn't exist yet. Module doc + imports + two public functions + one private helper + a#[cfg(test)]block with 8 unit tests. src/lib.rs— addpub mod compute;+ extend the re-export to includenotional_valueandunrealized_pnl.
Lesson 4 is the first lesson with running tests. From here every lesson adds tests until Lesson 8 (close_order_spec, the last of Stage 10a's behaviors).
Recap
After Lesson 3:
- The types module is byte-for-byte complete against Stage 10a —
MARGIN_SCALE,LiquidationParams,MarginRatio,MarginHealth,AccountSnapshot,CloseOrderSpec. - The compute module doesn't exist yet.
cargo buildpasses;cargo testruns zero tests.
Lesson 4 creates the compute module. The first two functions answer the question "what does this account currently look like?" — its notional exposure and its unrealized PnL. Lesson 5 builds equity and margin ratio on top of these.
Plan
Two edits:
- Create
crates/liquidation/src/compute.rs— module doc +usestatements pullingAccountSnapshot,MarkPricefrom Lessons 1–3 +notional_value+unrealized_pnl+ the privatesaturate_i128_to_i64helper + a#[cfg(test)]tests block with 3 notional tests + 5 PnL tests. - Update
src/lib.rs— addpub mod compute;and extend the public re-exports to include the two new function names.
(Answer: (mark − entry) × size, where size is signed i64. Walk through the four cases:
- Long (
size = +10), mark > entry: positive × positive = positive profit ✓ - Long (
size = +10), mark < entry: negative × positive = negative loss ✓ - Short (
size = −10), mark > entry: positive × negative = negative loss ✓ - Short (
size = −10), mark < entry: negative × negative = positive profit ✓
Every case lands on the right sign. No branching, no two-codepath testing, no risk that someone "fixes" one branch without the other. This is the load-bearing reason PositionSize is signed — the type carries the long/short distinction so the arithmetic doesn't have to.)
Dropping the four quadrants of (mark − entry) × size into one matrix makes it visually obvious why this single line replaces four if branches:
mark > entry mark < entry
(price up → diff +) (price down → diff −)
──────────────────── ────────────────────
Long (size = +) (+) × (+) = + (−) × (+) = −
◤ profit ✓ ◤ loss ✓
e.g. (110−100) × +10 = +100 e.g. (90−100) × +10 = −100
─────────────────────────────────────────────────────────────────────
Short (size = −) (+) × (−) = − (−) × (−) = +
◤ loss ✓ ◤ profit ✓
e.g. (110−100) × −10 = −100 e.g. (90−100) × −10 = +100
The mechanic: size's sign carries the long/short direction, (mark − entry)'s sign carries the price-move direction — multiplying them lets the two pieces of directional information combine, and the correct profit/loss sign falls out mechanically. In the if size > 0 { ... } else { ... } branched version, the developer has to mentally reconstruct both cases while writing each branch, and bugs that hit only one side are a common failure mode. The signed-multiplication form outsources that reconstruction entirely to the type system and arithmetic rules.
Walk-through
Step 1: Create src/compute.rs
Create crates/liquidation/src/compute.rs. The file doesn't exist yet. Initial content:
//! Pure liquidation math.
//!
//! Six building blocks, all stateless:
//! - [`notional_value`] — `|size| × mark`, the exposure in quote units
//! - [`unrealized_pnl`] — `(mark − avg_entry) × size`, signed
//! - [`account_equity`] — `collateral + unrealized_pnl`, can be negative
//! - [`margin_ratio`] — `equity / notional`, scaled by [`MARGIN_SCALE`]
//! - [`margin_health`] — classify the account against the params
//! - [`close_order_spec`] — generate the close order for a liquidatable
//! account
//!
//! Each function is deterministic and saturates on overflow rather than
//! wrapping or panicking. Validators that disagree about a margin
//! classification fork the chain, so the failure mode at network-
//! pathological inputs has to be bounded behavior.
use crate::types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
use openhl_clob::{Qty, Side};
use openhl_funding::MarkPrice;
The module doc names six functions — only two of them land in Lesson 4. The next four (account_equity, margin_ratio, margin_health, close_order_spec) come in Lessons 5–7. Listing all six upfront avoids re-editing the module doc at every lesson; it's also a roadmap for any reader who lands here without context.
Step 2: Add notional_value
Below the imports, add:
/// Notional exposure of the account = `|position_size| × mark`, in quote
/// units. Returns `0` for a flat position (no exposure regardless of mark).
///
/// `u64::saturating_mul` clips at `u64::MAX` for network-pathological
/// `position_size × mark` products. Real deployments are bounded by upstream
/// position-size limits; the saturation here is the second line of defense.
#[must_use]
pub fn notional_value(snapshot: &AccountSnapshot, mark: MarkPrice) -> u64 {
let abs_size = snapshot.position_size.0.unsigned_abs();
abs_size.saturating_mul(mark.0)
}
Three things to notice about this 7-line function:
-
Return type is
u64, noti64. Notional is the magnitude of exposure — always non-negative. Returningu64makes "did the caller forget to take abs?" impossible: the type system enforces it. A caller that wants to feed notional into a signed computation (likemargin_ratio's division) does an expliciti64::from(notional_value(...))at the call site. The conversion is one line; the bug it prevents is silent sign errors that survive into production. -
snapshot.position_size.0.unsigned_abs(), not.abs().i64::absreturnsi64— andi64::MIN.abs()is undefined in safe Rust (panics in debug, wraps in release).unsigned_absreturnsu64and is defined for every input, includingi64::MIN(i64::MIN.unsigned_abs() == 9_223_372_036_854_775_808). Useunsigned_abswhenever you need the magnitude of a signed integer; reserveabsonly when you're sure the value can't beMIN. -
u64::saturating_muloveru64::checked_mul. Both detect overflow;saturating_mulreturnsu64::MAXon overflow,checked_mulreturnsNone. ReturningOption<u64>would force every caller (margin_ratio in Lesson 5, etc.) to handle aNonethat only arises at network-pathological inputs. Saturating returns a usable value that's mathematically wrong only at the extremes — and at those extremes the margin engine will classify the account asLiquidatableeither way. Saturation is the right failure mode when "the value is extreme but stays in bounds" beats "the cost of forcing every call site to propagateOptionand write the boilerplate (?/unwrap_or/ early returns) that comes with it."
Step 3: Add unrealized_pnl
Below notional_value, add:
/// Unrealized PnL = `(mark − avg_entry) × position_size`, in quote units.
/// Positive = profit, negative = loss.
///
/// Sign convention follows the natural signed multiplication:
/// - Long position (size > 0) profits when `mark > entry` → positive
/// - Long position loses when `mark < entry` → negative
/// - Short position (size < 0) profits when `mark < entry` → negative
/// times negative is positive
/// - Flat position (size = 0) → 0
#[must_use]
pub fn unrealized_pnl(snapshot: &AccountSnapshot, mark: MarkPrice) -> i64 {
// diff = mark − entry, in i128 to preserve sign on subtraction.
let diff = i128::from(mark.0) - i128::from(snapshot.avg_entry.0);
// pnl = diff × size, in i128 to absorb the product's full range.
let pnl = diff.saturating_mul(i128::from(snapshot.position_size.0));
saturate_i128_to_i64(pnl)
}
Four things to notice:
-
i128::from(mark.0) − i128::from(snapshot.avg_entry.0), not(mark.0 as i64) − (snapshot.avg_entry.0 as i64). Bothmarkandentryareu64. Subtractingu64 − u64in Rust panics if the result would be negative; casting toi64first loses the top bit if either value exceedsi64::MAX. Upcasting toi128first preserves the full range and produces a signed result that can be negative without surprises. Upcast wider than you think you need; the cost is zero and the safety is enormous. -
The
saturating_mulis oni128. Adiffnearu64::MAX(≈ 2⁶⁴) times aposition_sizeneari64::MAX(≈ 2⁶³) is ≈ 2¹²⁷ — withini128's±2¹²⁷range butsaturating_mulis still cheap defense at the extremes. Saturation here matches funding's pattern. -
saturate_i128_to_i64(pnl)at the end. The PnL might be in i128 territory after the product, but the engine downstream usesi64. The helper saturates rather than panicking on conversion failure — same discipline. (Helper definition comes in Step 4.) -
Sign convention spelled out in the doc. The 4-case enumeration ("Long profits when mark > entry") is the canonical reference for any reviewer asking "wait, does this work for shorts?" The math gets it right by construction, but the doc says why — readers don't have to mentally walk through the cases each time.
Step 4: Add the saturate_i128_to_i64 helper
After unrealized_pnl, add the private helper:
/// Saturating cast from `i128` to `i64`. Used wherever an intermediate
/// product can exceed `i64::MAX` at network-pathological inputs.
/// Saturation, not wrapping — see the module-doc note on why panicking
/// would be a worse failure mode.
fn saturate_i128_to_i64(v: i128) -> i64 {
i64::try_from(v).unwrap_or(if v > 0 { i64::MAX } else { i64::MIN })
}
Three things to notice about this 3-line helper:
-
No
pub. This is an implementation detail ofcompute.rs. The public API is the six functions named in the module doc; the helper exists to keep their bodies clean. Keep helpers private unless callers in other modules genuinely need them. -
i64::try_from(v).unwrap_or(...).try_fromreturnsErrexactly when the value doesn't fit; theunwrap_orbranch picks the saturation target by sign. Forv > 0the value was too positive (saturate ati64::MAX); forv ≤ 0it was too negative (saturate ati64::MIN). Three lines of arithmetic; one decision point; impossible to typo. (Note: whenv == 0,try_fromalways succeeds withOk(0), so theelsebranch ofunwrap_or(i64::MIN) is never taken — i.e., theelseeffectively only fires "whenv < 0and doesn't fit," catching negative-direction saturation. Spelled out so readers don't burn a moment wondering whetherv == 0would somehow take thei64::MINpath.) -
No tests for the helper. The behavior is exhaustively tested through
unrealized_pnl's test cases (which exercise both happy-path and edge-of-range inputs). A separate test for the helper would be redundant.
Step 5: Add the tests
Below the helper, add the #[cfg(test)] block:
#[cfg(test)]
mod tests {
use super::*;
use openhl_clob::AccountId;
use openhl_funding::{Notional, PositionSize};
use proptest::prelude::*;
fn snapshot(size: i64, entry: u64, collateral: i64) -> AccountSnapshot {
AccountSnapshot {
account: AccountId(42),
position_size: PositionSize(size),
avg_entry: MarkPrice(entry),
collateral: Notional(collateral),
}
}
// ─── notional_value ───────────────────────────────────────────
#[test]
fn notional_long() {
let s = snapshot(10, 100, 0);
assert_eq!(notional_value(&s, MarkPrice(120)), 10 * 120);
}
#[test]
fn notional_short_uses_abs() {
let s = snapshot(-10, 100, 0);
assert_eq!(notional_value(&s, MarkPrice(120)), 10 * 120);
}
#[test]
fn notional_flat_is_zero() {
let s = snapshot(0, 100, 1_000);
assert_eq!(notional_value(&s, MarkPrice(120)), 0);
}
// ─── unrealized_pnl ───────────────────────────────────────────
#[test]
fn pnl_long_profit() {
// Long 10 @ entry 100; mark 120 → +200
let s = snapshot(10, 100, 0);
assert_eq!(unrealized_pnl(&s, MarkPrice(120)), 200);
}
#[test]
fn pnl_long_loss() {
// Long 10 @ entry 100; mark 80 → −200
let s = snapshot(10, 100, 0);
assert_eq!(unrealized_pnl(&s, MarkPrice(80)), -200);
}
#[test]
fn pnl_short_profit() {
// Short −10 @ entry 100; mark 80 → +200 (price down is good for short)
let s = snapshot(-10, 100, 0);
assert_eq!(unrealized_pnl(&s, MarkPrice(80)), 200);
}
#[test]
fn pnl_short_loss() {
// Short −10 @ entry 100; mark 120 → −200
let s = snapshot(-10, 100, 0);
assert_eq!(unrealized_pnl(&s, MarkPrice(120)), -200);
}
#[test]
fn pnl_flat_is_zero() {
let s = snapshot(0, 100, 0);
assert_eq!(unrealized_pnl(&s, MarkPrice(200)), 0);
}
}
Things to notice about the test block:
-
A
snapshot()helper at the top. Three integer args (size,entry,collateral) —accountis hardcoded toAccountId(42). The helper saves typing across 8+ tests and keeps each test's meaningful inputs (the sign of size, the relationship between entry and mark) visible. Test fixtures should expose what varies and hide what's constant. -
Four PnL cases mirror the four sign combinations from the predict callout.
pnl_long_profit,pnl_long_loss,pnl_short_profit,pnl_short_loss. Pluspnl_flat_is_zeroto nail the zero-size path. Every reachable sign combination has a test. Coverage of sign combinations is the load-bearing property — miss one and a future refactor can silently invert a side. -
use proptest::prelude::*;even though Lesson 4 has no proptests yet. The import lands here once and survives through Lessons 5 / 8 where proptests are added. Same reasoning as the bulk imports incompute.rsproper — write once at the boundary, tolerate the unused-import warning across the next few lessons. -
Test names are sentences.
pnl_long_profitreads as "PnL when long is in profit." When a test fails, the test name in the failure output is the first thing you see — make it descriptive enough that you don't need to read the body to know what broke.fn test_1,fn test_2are CI noise; sentence-fragment names are CI signal.
Step 6: Update src/lib.rs
Open crates/liquidation/src/lib.rs. Add pub mod compute; and extend the re-export. Was:
pub mod types;
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
Becomes:
pub mod compute;
pub mod types;
pub use compute::{notional_value, unrealized_pnl};
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
Two changes:
pub mod compute;abovepub mod types;— alphabetical, same as the existing convention.pub use compute::{notional_value, unrealized_pnl};— a new re-export line, separate from thetypesre-export so each module gets its own line. Lessons 5–7 will extend the compute list as more functions land.
Step 7: Run the tests
cargo test -p openhl-liquidation
Expected output:
running 8 tests
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 result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
The 8 tests are your proof that the signed-multiplication trick works for every sign combination. When you (or a future contributor) refactors unrealized_pnl, these tests are what keeps the sign convention honest.
Common errors:
warning: unused import: ...for any of the imports added in batch — expected, gone by Lesson 7.error[E0599]: no method named 'unsigned_abs' found for type 'i64'— you're on a very old Rust.unsigned_abswas stabilized in Rust 1.51 (2021). The project'srust-toolchain.tomlshould pin a recent enough version.- Test fails with
attempt to multiply with overflow— your build is in debug mode and you wrote*instead ofsaturating_mul. Replace.
Design reflection
Three load-bearing decisions in this lesson:
-
notional_value: u64,unrealized_pnl: i64. Return types signal invariants. Notional is never negative; PnL can be either side. Calling code that needs to mix them does the explicit conversion (i64::from(notional)). One conversion at the call site beats a class of silent-sign bugs that survive into production. -
Signed-multiplication symmetry over branching.
(mark − entry) × sizeresolves all four sign combinations correctly becausesizecarries the long/short sign. The branching alternative (if size > 0 { ... } else { ... }) splits the codepath in two, double-fields the test budget, and risks a "fix the long branch but forget the short branch" bug in some future refactor. Let the type system carry the cases the arithmetic naturally handles. -
unsigned_absoverabsfori64.i64::MIN.abs()is the canonical Rust footgun: panics in debug, silently wraps in release.unsigned_absreturnsu64and is defined for everyi64input. Pick the version of the operation that has no panic-path; the alternative is a debug-only crash that a release build will gladly hide.
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 4:
- compute.rs matches the first ~80 lines of Stage 10a's
compute.rs— module doc + imports +notional_value+unrealized_pnl+ helper + the first 8 tests. Everything below (the next four functions and their tests, plus 3 proptests) lands in Lessons 5–7. - lib.rs still misses 4 more compute re-exports (
account_equity,margin_ratio,margin_health,close_order_spec). Those arrive incrementally.
Common questions
Q1: Why is notional_value u64 but mark is also u64 — couldn't the product overflow u64?
It can, at network-pathological inputs (a position so large that |size| × mark > 2⁶⁴). That's what saturating_mul defends. In realistic markets this doesn't happen — exchange position-size limits keep notional far below u64::MAX. The saturation is the second line of defense; the first is upstream sanity checks.
Q2: Why is the helper saturate_i128_to_i64 private but notional_value and unrealized_pnl are public?
The helper is an implementation choice (saturating cast). The two public functions are part of the engine's contract — every callsite computing margin needs them. Public means "callers depend on this." Private means "this is how we happen to do it inside." A future refactor could replace saturate_i128_to_i64 with checked_mul + Option propagation without breaking any callers.
Q3: Could the signed-multiplication trick produce a wrong sign at the integer extremes?
Mathematically no — the four sign combinations come from elementary algebra. But arithmetically, yes: a product that overflows i64 (and then i128) loses information about the sign of the true result. That's why every intermediate product uses i128::saturating_mul and the final cast saturates at i64::MAX / i64::MIN depending on the sign of the i128 value. Saturation preserves the sign of the answer even when it loses the magnitude.
Q4: Should unrealized_pnl panic when mark == 0?
No — mark = 0 is bizarre but not undefined. The formula (0 − entry) × size = −entry × size is mathematically well-defined (and would classify the position as deeply underwater, which is correct behavior). Production deployments will refuse to publish a zero mark; if one slips through, the engine handles it gracefully. Pure functions don't decide policy; they compute on whatever inputs they get.
Q5: Why doesn't notional_value take &MarkPrice instead of MarkPrice?
MarkPrice is Copy and 8 bytes (u64). Pass-by-value is cheaper than pass-by-reference for Copy types this small — no pointer indirection, no aliasing concerns. Reach for & when the type is large enough that copying is expensive, OR when ownership semantics matter. For Copy newtypes around primitives, pass-by-value is the right default.
Next lesson (Lesson 5)
Lesson 5 adds account_equity and margin_ratio — and the most pedagogically loaded discovery in Stage 10a: the leveraged-regime non-monotonicity of margin_ratio. You'll write the proptest first ("as mark increases for a long, margin_ratio should also increase"), watch it fail at a small handful of inputs, trace through why the failure is real (and not a bug), and refine the proptest with prop_assume! to express the actual invariant. This is the lesson where a learner's first mental model of margin math gets broken and rebuilt.
Summary (3 lines)
notional_value = |size| * mark.unrealized_pnl = (mark - entry) * size. Signed-multiplication trick covers both sides.- Two formulas; both work for long + short. No branching. Saturating arithmetic; type-correct.
- Five tests cover the variants. Reusable everywhere downstream. Next: equity + margin_ratio + a proptest that breaks the first mental model.