FABRKNT
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
純粋な compute
レッスン 5 / 14·CONTENT45 分80 XP
コース
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
レッスンの役割
CONTENT
順序
5 / 14

レッスン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_valueu64unrealized_pnli64 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) × sizesize は 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/entryi64::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) × sizeif なしで 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_mulchecked_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 が収まらなければ Errunwrap_or が符号で行き先を選ぶ(v == 0try_fromOk(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_absabsi64::MIN で UB)、中間値は i128(u64-u64 panic / as キャスト wrap を回避)、overflow は saturate。

次のレッスン(レッスン5)

account_equity + margin_ratio を追加し、compute で最も load-bearing な発見 — levered regime での margin_ratio の非単調性 — に出会う。proptest を書き、失敗を見て、トレースし、prop_assume! で refine する。最初のメンタルモデルが壊れて再構築されるレッスン。