レッスン6 — compute_rate — divisor + cap
問い
premium を divisor で割り、±rate_cap に clamp して per-interval rate を作る。だが「割る」と「clamp する」の順序を逆にすると、cap の意味が 4%/interval から 0.5%/interval に静かにすり替わる。なぜ順序が cap の単位を決めるのか?
原理(最小モデル)
- 演算順が単位を決める。 先に割って それから clamp する。cap は
4%/intervalなので rate レベルで bind する必要がある。clamp してから割ると実効 cap がcap/divisor(HL デフォルトで0.5%/interval)にすり替わる。 .clamp(-cap, cap)で対称クランプ。 標準i64::clampが両側を一度に処理。min(raw, cap)で正側だけ clamp して負側を見落とすバグを構造的に防ぐ。- API 境界での defensive な
.abs()。FundingRate(-40_000_000)を絶対値として受け入れ、呼び出し側の footgun を減らす(コスト ~1ns)。cap は「幅」であって「位置」でない。 - 自然に成立する edge case は明示的な分岐より強い。
cap==0はclamp(0,0)=0から自動的にFundingRate(0)を生む。特例コードを書かない = テストすべきパスも増えない。 - property のない場所に proptest を強引に当てない。 「割って clamp」には代数的不変条件がない。手書きトレースで入力領域をカバーすれば十分。
具体例
premium が 100%(RATE_SCALE ppb)のとき、順序で最終 rate が 8 倍変わる(HL デフォルト divisor=8, cap=±4%):
🟢 A (今回の実装) divide → clamp
Premium 100% ─/8─► raw 12.5% ─clamp(±4%)─► 4% ✨ spec 通り
🔴 B (順序逆転) clamp → divide
Premium 100% ─clamp(±4%)─► 4% ─/8─► 0.5% ❌ cap が「premium 上限」にすり替わり spec の 1/8
同じ premium/divisor/cap でも 2 行を入れ替えるだけで 4% と 0.5% に着地する。コンパイラもテストも警告しない純粋な semantics バグ。「cap の単位は出力(rate)の単位に合わせる」がその規律。
失敗例(誤解)
「params.rate_cap == 0 を特殊ケースとして扱うべき」は誤り — 自然に処理される。cap==0 のとき clamp(-0, 0) は入力に関わらず 0 を返し、FundingRate(0)(funding 無効化)になる。edge case が自然に処理されるコードのほうが、明示的な分岐より良い(テストすべきパスが増えない)。
ここまでで「割ってから clamp」「cap は絶対値」は着地した。ここから 2 つ目の pure 関数を足す(compute_premium より短い — 値が既に i64 に収まるので overflow 体操がない)。コードは完全形。
🛑 予測。 先に clamp してから割るとどう変わるか?(答え: cap が「最大 rate」でなく「最大 premium」を意味するようになる。
cap=4%,divisor=8で premium を ±4% に clamp してから割ると最大 rate は 0.5%/interval。今回(先に割って rate レベルで clamp)なら cap がそのまま 4%/interval で bind。cap の単位は出力の単位に合わせる必要がある。)
ステップで組み立てる
Step 1: compute_rate を追加
compute_premium の後、saturate_i128_to_i64 の前に:
/// Divide the premium by `params.divisor` and clamp to ±`params.rate_cap`.
///
/// `divisor == 0` is treated as "funding disabled" → returns `FundingRate(0)`,
/// which causes `apply_funding` to produce zero-delta settlements for every
/// position (or none, by the filter inside `apply_funding`).
#[must_use]
pub fn compute_rate(premium: Premium, params: FundingParams) -> FundingRate {
if params.divisor == 0 {
return FundingRate(0);
}
let raw = premium.0 / i64::from(params.divisor);
let cap = params.rate_cap.0.abs();
let capped = raw.clamp(-cap, cap);
FundingRate(capped)
}
動く部分 4 つ: divisor==0 早期 return(funding 無効化 + ゼロ除算 panic 回避)/ i64::from(u32) でロスレス widen して除算(生 rate)/ .abs() で cap を magnitude に / .clamp(-cap, cap) で対称 clamp(手書き if/else 不要)。
Step 2: 5 unit test を追加
premium テストの後(proptest ブロックの前)に:
#[test]
fn rate_divides_premium_by_divisor() {
let params = FundingParams::hyperliquid_default();
// premium = 0.01 (10_000_000 ppb), divisor = 8 → rate = 1_250_000
let r = compute_rate(Premium(10_000_000), params);
assert_eq!(r, FundingRate(1_250_000));
}
#[test]
fn rate_clamps_at_positive_cap() {
let params = FundingParams::hyperliquid_default();
// premium = 1.0 (RATE_SCALE), divisor = 8 → raw = 125_000_000
// cap is 40_000_000 → clamps to 40_000_000.
let r = compute_rate(Premium(RATE_SCALE), params);
assert_eq!(r, FundingRate(40_000_000));
}
#[test]
fn rate_clamps_at_negative_cap() {
let params = FundingParams::hyperliquid_default();
let r = compute_rate(Premium(-RATE_SCALE), params);
assert_eq!(r, FundingRate(-40_000_000));
}
#[test]
fn rate_zero_when_divisor_is_zero() {
let mut params = FundingParams::hyperliquid_default();
params.divisor = 0;
let r = compute_rate(Premium(RATE_SCALE), params);
assert_eq!(r, FundingRate(0));
}
#[test]
fn rate_zero_when_cap_is_zero_funding_disabled() {
let mut params = FundingParams::hyperliquid_default();
params.rate_cap = FundingRate(0);
let r = compute_rate(Premium(10_000_000), params);
assert_eq!(r, FundingRate(0));
}
5 つ: 通常除算 / 正側 clamp / 負側 clamp(min(raw,cap) の負側見落としを捕まえる)/ divisor=0 無効化(ゼロ除算 guard を捕まえる)/ cap=0 無効化(clamp(0,0) の自然処理を確認)。
Step 3: lib.rs を更新
pub use compute::{compute_premium, compute_rate};
Step 4: テスト実行
cargo test -p openhl-funding が 10 pass(premium 5 + rate 5)。
答え合わせ
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 + compute_rate + helper + 4 premium + 5 rate + 1 proptest まで一致(apply_funding + zero-sum proptest はレッスン7)。
合格基準
cargo test -p openhl-funding が 10 pass。よくあるエラー: rate_zero_when_divisor_is_zero で panic(guard 忘れ)/ min/max の順序間違いで負側 clamp 失敗(.clamp を使う)/ premium.0 / params.divisor の型混在(i64::from(...) を使う、as i64 の typo は truncate しうる)。
まとめ(3行)
compute_rateは premium を divisor で割り±rate_cap.abs()に clamp。割ってから clamp が肝 — 逆順は cap を実効cap/divisorに弱める silent な semantics バグ。.clamp(-cap, cap)で対称クランプ(正側だけのminバグを構造的に防ぐ)、cap は.abs()で magnitude 扱い(負 cap を defensive に受け入れる)。divisor==0/cap==0はどちらもFundingRate(0)(funding 無効化)— cap==0 はclamp(0,0)から自然に落ちる。代数的 property がないので proptest なし。
次のレッスン(レッスン7)
apply_funding を追加(最後の pure 関数)。Position スライス + MarkPrice + FundingRate → Vec<Settlement>。longs-pay-shorts の符号規約(単項マイナス 1 つ)と balanced-book zero-sum proptest(funding は再配分するだけ)でセクション2 を閉じる。