FABRKNT
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
純粋な compute
レッスン 7 / 12·CONTENT30 分60 XP
コース
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
レッスンの役割
CONTENT
順序
7 / 12

レッスン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==0clamp(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 + FundingRateVec<Settlement>。longs-pay-shorts の符号規約(単項マイナス 1 つ)と balanced-book zero-sum proptest(funding は再配分するだけ)でセクション2 を閉じる。