レッスン4 — compute_premium — 最初の数学、最初のテスト
問い
(mark - index) / index を整数で計算したい。だが u64 の引き算は mark < index で underflow し、整数除算を先にすると 100% 未満の premium が全部 0 に丸まる。符号を保ちつつ精度も残すには、型と演算順をどう組むか?
原理(最小モデル)
- 整数幅は入力でなく 中間値 のレンジで選ぶ。
mark/indexはu64だが(mark-index)*RATE_SCALEは最悪 ~1.8e28。i128 中間値は必須、しかも upcast を引き算の 前 に入れて初めて符号が保たれる。 - 割る前に掛ければ精度が残る。
(mark-index)/indexを整数で先に計算すると 100% 未満の premium が全部 0 に丸まる。先にRATE_SCALEを掛けて分数を i128 マグニチュードに変換し、その後で割る。 u64引き算が王道の符号バグ。99 - 100を u64 でやるとu64::MAX近くに wrap(小さな負でなく巨大な正)。i128::from(...)upcast で代数的に正しくなる。- oracle 欠損は graceful degradation。
index==0はPremium(0)を返しエラーにしない(funding は bridge 経由で balance update に流れるのでErrは無関係な payload まで tx 失敗にする)。早期 return がゼロ除算 panic も同時に防ぐ。 - テストコメントは紙の上の数学そのもの。
// (101-100)*1e9/100 = 10_000_000を assertion 横に書けば、将来の debugger は「作者を信じる」でなく「数式に照らして検証」できる。
具体例
compute_premium 内で型がどこで widen/saturate/narrow するか:
MarkPrice(u64) ──► i128 ──┐
▼
IndexPrice(u64) ──► i128 ─► [ - 引き算 ] ──► diff (i128: 符号が安全に残る)
│
RATE_SCALE(i64) ──► i128 ──► [ saturating_mul ] ─► scaled (i128, overflow を clamp)
│
IndexPrice(u64) ──► i128 ──────────────► [ / 除算 ] (※ index==0 は事前に弾く)
▼
Premium(pub i64) ◄──── [ saturate_i128_to_i64 ] ◄── premium (i128)
入力(MarkPrice/IndexPrice/RATE_SCALE)と出力(Premium)は narrow、中間値だけ意図的に i128 に widen。overflow は saturating_mul と最後の saturate_i128_to_i64 の 2 箇所で吸収(間の diff には不要)。「掛けてから割る」が scaled ラベルの位置に現れる。
失敗例(誤解)
「(mark - index).saturating_mul(RATE_SCALE) / index を u64 で計算」は誤り — 引き算が問題。MarkPrice(99) - IndexPrice(100) を u64 でやると underflow し u64::MAX 近くに wrap(小さな負でなく巨大な正)。本来小さな負の premium が巨大な正になる。符号が肝心、符号付き演算が必須。
ここまでで「i128 中間値 + 掛けてから割る」は着地した。ここから実際の数学を持つ最初のレッスンに入る(これ以降、コード変更でアカウント間に wealth が静かに移りうる — 手書きトレースのテストが期待値を紙の数学に pin する)。コードは完全形。
🛑 予測。
(mark - index) * RATE_SCALEの最大サイズは(mark/indexは u64、RATE_SCALE = 1e9)? どの型に収まる必要があるか。(答え:(u64::MAX - 1) * 1e9 ≈ 1.8e28。i64::MAX~9.2e18 なので中間値に i128 必須。index で割った後は i64 に戻るが、除算は乗算の 後 なので中間が i128 に収まることが必須。積に i128、saturation が最終結果の稀な i64 超えを扱う。)
ステップで組み立てる
Step 1: compute.rs を module doc 付きで作成
//! Pure funding-rate math.
//!
//! Three building blocks, each stateless:
//! - [`compute_premium`] derives the mark/index gap as a signed fraction
//! - [`compute_rate`] divides + caps to produce a per-interval rate
//! - [`apply_funding`] turns a rate + position snapshot into settlements
//!
//! Each function is deterministic and saturates on overflow rather than
//! wrapping. Validators that disagree about funding fork the chain, so the
//! cost of an unexpected overflow has to be bounded behavior, not panic.
use crate::types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, Premium, Settlement,
RATE_SCALE,
};
module doc が 3 関数をプレビュー([compute_rate]/[apply_funding] はレッスン6/7 までリンク切れ、warning 許容)。use block はレッスン6/7 で使う型も今 import(boilerplate を先に安定化させ、レッスンごとの diff を本質に集中させる)。
Step 2: compute_premium を追加
/// Compute the premium `(mark - index) / index`, scaled by [`RATE_SCALE`].
///
/// Returns `Premium(0)` if `index == 0` — the safest behavior, since with no
/// reliable reference price the funding rate should not push capital around.
/// Real deployments should guard upstream (e.g., refuse to tick when the
/// oracle is missing); the saturation here is the second line of defense.
#[must_use]
pub fn compute_premium(mark: MarkPrice, index: IndexPrice) -> Premium {
if index.0 == 0 {
return Premium(0);
}
// (mark - index) as i128 so we can't lose sign on subtraction; multiply
// by RATE_SCALE in i128 to avoid overflow before the divide.
let diff = i128::from(mark.0) - i128::from(index.0);
let scaled = diff.saturating_mul(i128::from(RATE_SCALE));
let premium = scaled / i128::from(index.0);
// Saturate back to i64 — at i64 range with index prices in u64::MAX
// territory, this only clips at network-pathological inputs.
Premium(saturate_i128_to_i64(premium))
}
動く部分 4 つ: index==0 早期 return(graceful degradation + 直後の scaled / index のゼロ除算 panic も同じ 2 行で封じる)/ 引き算の前に i128 upcast(u64 underflow 回避)/ saturating_mul(panic/wrap でなく i128::MAX/MIN に clamp)/ 割るのは掛けた後(精度保持)。
Step 3: saturate_i128_to_i64 helper を追加
/// Clamp an `i128` into the `i64` range. Used wherever an intermediate
/// product can exceed `i64::MAX` at network-pathological inputs (e.g., a
/// `u64::MAX` index price). Saturation, not wrapping — see the module-doc
/// comment 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 })
}
i64::try_from(v) が Result、unwrap_or が overflow 方向に応じた default を返す(正なら i64::MAX、負なら i64::MIN)。module private(fn、呼び出し側は MarkPrice→Premium を見るだけ)。レッスン7 の apply_funding が 2 番目の caller — だから inline でなく独立 helper にする。
Step 4: テストモジュール + 4 unit test
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn premium_zero_when_mark_equals_index() {
let p = compute_premium(MarkPrice(100), IndexPrice(100));
assert_eq!(p, Premium(0));
}
#[test]
fn premium_positive_when_mark_above_index() {
// mark 101, index 100 → premium = 1/100 = 0.01 → 10_000_000 ppb
let p = compute_premium(MarkPrice(101), IndexPrice(100));
assert_eq!(p, Premium(10_000_000));
}
#[test]
fn premium_negative_when_mark_below_index() {
let p = compute_premium(MarkPrice(99), IndexPrice(100));
assert_eq!(p, Premium(-10_000_000));
}
#[test]
fn premium_saturates_to_zero_when_index_is_zero() {
let p = compute_premium(MarkPrice(1_000_000), IndexPrice(0));
assert_eq!(p, Premium(0));
}
}
手書きトレース 4 つ(equal→0 / positive→longs overpay / negative→u64 underflow バグを捕まえる / index-zero→graceful guard)。コメント // (101-100)*1e9/100 = 10_000_000 が紙の数学。pathological 入力(u64::MAX 等)の境界テストはレッスン5。
Step 5: lib.rs を更新
pub mod compute;
pub mod types;
pub use compute::compute_premium;
pub use types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
Premium, Settlement, RATE_SCALE,
};
モジュール宣言・re-export ともアルファベット順(compute が types の前)。
Step 6: テスト実行
cargo test -p openhl-funding
warning: unresolved link to `compute_rate`
warning: unresolved link to `apply_funding`
warning: unresolved link to `FundingClock`
running 4 tests
test compute::tests::premium_negative_when_mark_below_index ... ok
test compute::tests::premium_positive_when_mark_above_index ... ok
test compute::tests::premium_saturates_to_zero_when_index_is_zero ... ok
test compute::tests::premium_zero_when_mark_equals_index ... ok
test result: ok. 4 passed; 0 failed; ...
crate 初の green run。rustdoc warning 3 つは期待通り(レッスン6/7/8 で解決)。
答え合わせ
cd ~/code/openhl-reference && git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/compute.rs ./crates/funding/src/compute.rs
diff -u ~/code/my-openhl/crates/funding/src/lib.rs ./crates/funding/src/lib.rs
git checkout main
compute.rs は compute_premium + saturate_i128_to_i64 + 4 premium テストまで一致(compute_rate/apply_funding/proptest はレッスン5-7)。
合格基準
cargo test -p openhl-funding が 4 pass。よくあるエラー: * RATE_SCALE 抜けで left=0(整数除算が先に 0 に丸める)/ i128 upcast 抜けで left=18446744073709541616(u64 underflow wrap)/ saturating_mul でなく * で panic(debug overflow)/ helper の宣言順は Rust が気にしない。
まとめ(3行)
compute_premiumは(mark-index)/indexをRATE_SCALEスケールで返す。中間値をi128に widen(入力は narrow なまま)し、引き算の前に upcast して符号を保つ。- 「掛けてから割る」で精度を残し、overflow は
saturating_mul+saturate_i128_to_i64で吸収(panic/wrap は consensus を壊すので不可)。 index==0はPremium(0)で graceful degradation(兼ゼロ除算 panic 回避)。テストコメントが紙の数学で、debugger が assertion を数式に照らせる。
次のレッスン(レッスン5)
新関数なし。overflow 哲学を深掘り(consensus で許される overflow は saturate だけ — panic は halt 経由 fork、wrap は誤値 fork)し、crate 初の proptest premium_is_antisymmetric_in_mark_index を追加する。