レッスン4 — notional_value + unrealized_pnl — signed-multiplication のトリック
問い
unrealized PnL は long が利益でも short が利益でも 正 の値を返したい。素朴には if size > 0 { (mark-entry)*|size| } else { (entry-mark)*|size| } と分岐する。だが long/short の 4 通りの符号を if なしで正しく捌く単一の式はあるか? そして u64 の引き算 underflow / i64 overflow をどう防ぐ?
原理(最小モデル)
notional_valueはu64、unrealized_pnlはi64。 notional exposure =|size| × mark(常に非負)、PnL =mark - entryが両側に振れる(signed)。返り型で符号を取り違えるバグをコンパイラが捕まえる。i64の magnitude にはabs()でなくunsigned_abs()。i64::MIN.abs()は overflow(正のi64::MINは表現できない)。unsigned_abs()はu64を返し panic しない。- 分岐なしで long/short を捌く signed-multiplication。
(mark - entry) × size(sizeは signed)で 4 通りの符号が自然に着地する。if side == Longを一度も書かない。 - i128 中間値の規律。 符号を保ったまま減算(
i128::from(mark.0) - i128::from(entry.0))→ overflow しない積 →i64へ saturate。funding のcompute_premiumと同じ形。
具体例
(mark - entry) × size の 4 象限:
mark > entry (diff = +) mark < entry (diff = −)
Long (size = +) (+)×(+) = + profit ✓ (−)×(+) = − loss ✓
(110−100)×+10 = +100 (90−100)×+10 = −100
Short (size = −) (+)×(−) = − loss ✓ (−)×(−) = + profit ✓
(110−100)×−10 = −100 (90−100)×−10 = +100
size の符号が long/short の方向を、(mark - entry) の符号が値動きの方向を運び、積を取った瞬間に正しい profit/loss の符号が機械的に出る。if 分岐版は開発者が両 case を頭で再構築するため片側だけバグが残りやすい — signed multiplication はその再構築を型 + 算術ルールに外注する。
失敗例(誤解)
「(mark.0 as i64 - entry.0 as i64) × size を直接書けばいい」は誤り、問題 3 つ: (1) mark/entry が i64::MAX 超で as キャストが silent に wrap(最上位ビットが符号ビットに化ける)。(2) 両方が i64 に収まっても片方が i64::MIN 近く+他方が正で i64 減算が overflow。(3) 各オペランドが収まっても積 (mark-entry)×size が i64 を超えうる(i64::MAX サイズの position なら 1% の値動きで overflow)。as キャストは Rust 屈指の footgun。
ここまでで「signed-multiplication + i128 中間値」は着地した。ここから crate 初の挙動テストが走る(これ以降コード変更でアカウント間に wealth が動きうる)。コードは完全形。
🛑 予測。
(mark - entry) × sizeをifなしで 4 符号すべて正しく捌けるのはなぜか?(ヒント:size自身が long/short の符号を運んでいたら計算がどう転ぶか)(答え:sizeを signed i64 のまま掛ければ、Long+/Long−/Short+/Short− の 4 ケースで符号が正しく着地する。分岐がなく、コードパスが 2 本に分かれず、片方だけ「直して」他方を放置するリスクもない。PositionSizeを signed にしたのはこのため — 型が long/short を運べば演算側が運ばなくて済む。)
ステップで組み立てる
Step 1: src/compute.rs を作成
//! 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;
module doc が 6 関数をプレビュー(レッスン4 は 2 つ着地、残りは L5-7)。use block は後のレッスンが使う型も今 import(レッスン7 まで unused warning が出るが許容 — use を 6 回いじる busywork より各レッスンの diff を絞る)。
Step 2: notional_value を追加
/// 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)
}
返り型 u64(notional は magnitude、常に非負 — 呼び出し側の abs 取り忘れを型で潰す)。unsigned_abs()(.abs() でない — i64::MIN.abs() は UB)。saturating_mul(checked_mul でない — Option を全呼び出し側に伝播させない、極端入力でも使える値を返す)。
Step 3: unrealized_pnl を追加
/// 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)
}
i128::from(...) - i128::from(...)(u64 - u64 は負で panic、as i64 は最上位ビットが落ちる — 一段広く upcast、コストゼロ)。saturating_mul を i128 上で。末尾 saturate_i128_to_i64。符号ルールを doc に明文化(レビュアーの「short でも動く?」への正典参照)。
Step 4: saturate_i128_to_i64 ヘルパー
/// 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 })
}
private(pub なし、内部実装の選択)。try_from が収まらなければ Err、unwrap_or が符号で行き先を選ぶ(v == 0 は try_from が Ok(0) を返すので else は実質「v<0 の負方向 saturation」だけ拾う)。ヘルパー単体テストは置かない(unrealized_pnl のテストでカバー)。
Step 5: テストを追加
#[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);
}
}
snapshot() helper(変化する 3 値を表に、account をハードコード)。PnL 4 ケースが予測の 4 符号と一対一 + flat。use proptest::prelude::* は今書く(L5/L8 で使う、unused warning 許容)。テスト名は文として読める(CI のシグナル)。
Step 6: src/lib.rs を更新
pub mod compute;
pub mod types;
pub use compute::{notional_value, unrealized_pnl};
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};
Step 7: テスト実行
cargo test -p openhl-liquidation が 8 pass(notional 3 + pnl 5)。crate 初の green run。
答え合わせ
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 は最初の ~80 行(module doc + import + notional_value + unrealized_pnl + helper + 8 テスト)まで一致。残り 4 関数 + proptest は L5-7。
合格基準
cargo test -p openhl-liquidation が 8 pass。よくあるエラー: unused import warning(まとめ import、L7 までに消える、想定どおり)/ unsigned_abs 未解決(Rust が古い、1.51+ で安定)/ * で overflow panic(saturating_mul に置換)。
まとめ(3行)
notional_value: u64(magnitude、非負)/unrealized_pnl: i64(両側に振れる)— 返り型が不変量を表現。混ぜたい呼び出し側は明示変換(1 行 < silent な符号バグ群)。(mark - entry) × size(signed)が 4 符号を分岐なしで捌く — 演算が自然に扱えるケースは型システムに運ばせる。- magnitude には
unsigned_abs(absはi64::MINで UB)、中間値は i128(u64-u64panic / as キャスト wrap を回避)、overflow は saturate。
次のレッスン(レッスン5)
account_equity + margin_ratio を追加し、compute で最も load-bearing な発見 — levered regime での margin_ratio の非単調性 — に出会う。proptest を書き、失敗を見て、トレースし、prop_assume! で refine する。最初のメンタルモデルが壊れて再構築されるレッスン。