Lesson 6 — compute_rate — divisor + cap
Question
compute_rate(premium, divisor, cap) -> FundingRate = clamp(premium / divisor, -cap, +cap). Three lines but two new concepts: integer division and a typed clamp.
Principle (minimum model)
compute_ratesignature.fn compute_rate(premium: Premium, divisor: i64, cap: FundingRate) -> FundingRate. Premium has unit, divisor and cap have units.- Integer division rounds toward zero.
premium / divisortruncates fractional part. Saturates on overflow. - Cap is clamping.
clamp(rate, -cap, +cap)ensures the rate stays in the consensus-safe range (±4 % on Hyperliquid). - Hyperliquid params. Divisor = 8; cap = ±4 % per hour = ±40_000_000 scaled (4 % * 10⁹).
- Why divisor? Smaller rate paid frequently > one big rate paid rarely. Limits per-interval impact on margin.
- Why cap? Worst-case bound. With cap = ±4 %, a position can never lose more than 4 % notional per interval to funding.
- Tests cover both axes. Below-cap regime (rate proportional to premium); above-cap regime (rate = cap).
- Proptest. Universal: |rate| ≤ cap for any premium. Trivially-true but catches sign bugs.
Worked example + steps
Lesson 6 — compute_rate — divisor + cap
Goal
Concepts you'll grasp in this lesson:
- Order of operations preserves units — divide first, then clamp. The cap is
4%/intervalso it must bind at the rate level. Clamp-then-divide would silently turn the cap intocap/divisor(e.g.,0.5%/intervalwith HL defaults), invisibly weakening the spec. - Symmetric clamp via
.clamp(-cap, cap)— Rust's built-ini64::clampreads top-to-bottom and applies both sides at once; the common bug pattern ismin(raw, cap)(positive only) leaving the negative side unclamped. - Defensive
.abs()at the API boundary — acceptingFundingRate(-40_000_000)as a cap and treating it as magnitude is one less footgun for callers who reasonably expect either sign to work. ~1 ns cost, real safety. - Edge cases that fall out naturally beat explicit branches —
cap == 0producesFundingRate(0)without a special-case becauseclamp(0, 0) = 0. No extra code path means no extra path to test. - No proptest where there's no property —
compute_rateis "divide and clamp"; there's no algebraic invariant for proptest to shine on. Hand-traced tests cover the distinct input regions; resist forcing a proptest where there isn't one.
Verification:
cargo test -p openhl-funding
…passes 10 tests (5 from Lessons 4–5 + 5 new).
Specific changes:
compute.rs gains:
compute_rate(premium, params) -> FundingRate— turns a raw premium into a per-interval rate by dividing byparams.divisorand clamping to±params.rate_cap.- 5 unit tests covering: the divisor effect, the positive cap clamp, the negative cap clamp, the disabled-when-divisor-zero case, the disabled-when-cap-zero case.
After Lesson 6, two of compute.rs's three pure functions are done. apply_funding is the only one left — Lesson 7.
The teaching focus is the order of operations: divide then clamp. Reversing that order would change the rate cap's meaning entirely — and it's the kind of off-by-one design bug that's easy to introduce and hard to detect.
Recap
After Lesson 5:
compute_premiumproduces a signed premium from mark/index.- The antisymmetry proptest exercises 256 random pairs.
saturate_i128_to_i64is in place but only used bycompute_premiumso far.
Lesson 6 adds the second pure function. compute_rate is shorter than compute_premium (no overflow gymnastics — the values it processes already fit in i64) but encodes its own set of design decisions.
Plan
Two edits:
- Append
compute_ratetocompute.rs— 10 lines of body, aftercompute_premium(beforesaturate_i128_to_i64). - Append 5 unit tests to the existing
mod testsblock. - Update
lib.rs— addcompute_rateto thepub use compute::{...}re-export.
(Answer: Clamping first would make the cap mean "the maximum premium," not "the maximum rate." With cap = 4%/interval and divisor = 8, clamping the premium to ±4% and then dividing produces a maximum rate of 0.5%/interval. With our approach (divide first, then clamp at the rate level), the cap genuinely binds at 4%/interval. The cap's unit must match the output's unit. Premium and rate are both scaled by RATE_SCALE, so they look numerically similar — but they're semantically different. The divisor changes which one you're capping.)
Walk-through
Step 1: Add compute_rate
Open crates/funding/src/compute.rs. After compute_premium, before saturate_i128_to_i64, add:
/// 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)
}
10 lines of body. Four moving parts:
-
if params.divisor == 0 { return FundingRate(0); }— the funding-disabled early exit. Without this, thepremium.0 / i64::from(params.divisor)line would panic (division by zero). A guard is the only safe response to a divisor of zero. -
premium.0 / i64::from(params.divisor)— the division.premium.0isi64;divisorisu32.i64::from(u32)widens losslessly (any u32 value fits in i64). Theni64 / i64produces an i64 quotient. The result is the "raw" per-interval rate before any clamping. -
let cap = params.rate_cap.0.abs();— extract the cap as an absolute value.params.rate_capis aFundingRate(i64), and the user might have provided a negative value. We don't care about the sign of the cap — we care about the magnitude. The cap is a width, not a position. -
raw.clamp(-cap, cap)— the symmetric clamp.i64::clamp(min, max)returnsminifraw < min,maxifraw > max, elseraw. Built-in Rust API; no manualif/elsechain needed.
Step 2: Why divide first
The order matters. Two alternatives:
A) Our approach: divide, then clamp
let raw = premium / divisor;
let capped = raw.clamp(-cap, cap);
- Cap binds at the rate level.
cap = 4%/intervalmeans "no single interval can pay more than 4%."- Premium of
100%with divisor8→ raw12.5%, clamped to4%.
B) Reverse: clamp, then divide
let capped_premium = premium.clamp(-cap, cap);
let raw = capped_premium / divisor;
- Cap binds at the premium level.
cap = 4%means "no single premium reading can exceed 4%."- Premium of
100%clamped to4%, then divided by8→ final rate0.5%.
Approach A is what we want. Approach B would make the cap effectively 0.5%/interval (rate_cap divided by divisor), which isn't what the docstring promises.
With a 100% premium (= RATE_SCALE ppb), the two approaches diverge dramatically from the same input — the data flow makes the gap obvious:
HL defaults: divisor = 8, cap = ±4%
🟢 Approach A (the implementation) — divide → clamp
┌─ Premium: 100% (1_000_000_000 ppb) ─┐
│ │
│ ┌─ / divisor 8 ──► raw rate: 12.5% (125_000_000 ppb)
│ │ │
│ │ ▼
│ │ ┌─ clamp(-4%, +4%) ─► 4% (40_000_000 ppb) ✨ correct
│ ▼ │ │
└────────────┴──────────────────────┘ ▼
[FundingRate: 4%/interval]
= spec ceiling binds correctly
🔴 Approach B (order reversed, wrong) — clamp → divide
┌─ Premium: 100% (1_000_000_000 ppb) ─┐
│ │
│ ┌─ clamp(-4%, +4%) ──► clamped premium: 4% (40_000_000 ppb)
│ │ │
│ │ ▼
│ │ ┌─ / divisor 8 ──► 0.5% (5_000_000 ppb) ❌ 1/8 of spec
│ ▼ │ │
└────────────┴──────────────┘ ▼
[FundingRate: 0.5%/interval]
= cap silently mutates from "rate ceiling"
into "premium ceiling", and the effective
ceiling shrinks to spec/divisor
Same premium / divisor / cap go in, but flipping two lines inside the function yields 4% vs 0.5% — an 8x semantic discrepancy that no compiler warning and no unit-test type signature will surface. The discipline that prevents it compresses into one sentence: the cap's unit must match the output's unit (rate).
(Answer: FundingRate(40_000_000) = 4%/interval. Walk through: premium.0 = 1_000_000_000 (RATE_SCALE). raw = 1_000_000_000 / 8 = 125_000_000 (12.5%/interval). cap = 40_000_000 (4%). clamp(-40_000_000, 40_000_000) on 125_000_000 → 40_000_000. The cap does its job. Compare to approach B: clamped_premium = clamp(1_000_000_000) at cap 40_000_000 → 40_000_000. raw = 40_000_000 / 8 = 5_000_000 (0.5%). Way under the spec.)
Step 3: Add 5 unit tests
In the #[cfg(test)] mod tests block, after the existing premium tests (and before the proptest block), add:
#[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 tests, each pinning a specific behavior:
-
rate_divides_premium_by_divisor— the normal case. Premium 1% (10_000_000 ppb), divisor 8 → rate 0.125% (1_250_000 ppb). The expected value is the paper math10_000_000 / 8 = 1_250_000. Catches off-by-one in the division. -
rate_clamps_at_positive_cap— clamping kicks in when the premium would produce a raw rate above the cap. Premium 100% → raw 12.5% → clamped to 4%. Catches: "I forgot to clamp" bugs. -
rate_clamps_at_negative_cap— symmetric to #2 on the negative side. Premium -100% → raw -12.5% → clamped to -4%. Catches: "I clamped only the positive side" bugs. This is a real bug pattern; people writemin(raw, cap)instead ofraw.clamp(-cap, cap)and miss the negative side. -
rate_zero_when_divisor_is_zero— the disabled-funding case via divisor. Even with a non-zero premium,divisor = 0makes the function return zero. Catches: forgot to guard against division-by-zero. Without the guard, this test would panic in debug mode. -
rate_zero_when_cap_is_zero_funding_disabled— the disabled-funding case via cap. Withrate_cap = 0, the clamp is[0, 0], so any raw rate clamps to 0. Catches: assuming clamp(0, 0) does something other than return 0. Also confirms our "no special case for cap == 0" approach works.
(Answer: Same result — FundingRate(40_000_000). Because .abs() extracts the magnitude. Negative caps and positive caps with the same absolute value produce identical behavior. The "negative cap" is silently accepted. This is the defensive abs at work — the user gets reasonable behavior either way.)
Step 4: Update lib.rs
The current re-export line:
pub use compute::compute_premium;
Becomes:
pub use compute::{compute_premium, compute_rate};
Two functions now in the public API. Alphabetical order maintained — compute_premium before compute_rate. The pattern continues with Lesson 7 when apply_funding arrives.
Step 5: Run tests
cargo test -p openhl-funding
Expected output:
running 10 tests
test compute::tests::premium_is_antisymmetric_in_mark_index ... ok
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 compute::tests::rate_clamps_at_negative_cap ... ok
test compute::tests::rate_clamps_at_positive_cap ... ok
test compute::tests::rate_divides_premium_by_divisor ... ok
test compute::tests::rate_zero_when_cap_is_zero_funding_disabled ... ok
test compute::tests::rate_zero_when_divisor_is_zero ... ok
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
10 tests, all green. Rate tests + premium tests + proptest.
Common errors:
- Panic in
rate_zero_when_divisor_is_zero— you forgot the early-return guard.premium.0 / 0is an arithmetic panic in Rust. Addif params.divisor == 0 { return FundingRate(0); }at the top of the function. assertion failed: left=-125000000 right=-40000000inrate_clamps_at_negative_cap— you wroteraw.min(cap).max(-cap)instead ofraw.clamp(-cap, cap), and got the min/max order wrong..clamp(min, max)is the canonical Rust idiom; use it.assertion failed: left=0 right=1_250_000inrate_divides_premium_by_divisor— you wrotepremium.0 / params.divisor(mixed types) instead ofpremium.0 / i64::from(params.divisor). The error is actually a compile error (u32 vs i64mismatch); if you typo'das i64it might compile but truncate. Usei64::from(...).error: cannot find function 'compute_rate'inlib.rsre-export — you addedcompute_rateto the re-export but didn't define the function. Check that you actually added the function body tocompute.rs.
Design reflection
Four load-bearing decisions in this lesson:
-
Divide first, then clamp. The cap binds at the rate level (the output), not the premium level (the input). Reversing the order would effectively divide the cap by the divisor, silently weakening it. Order-of-operations matters when units differ.
-
.abs()on the cap. Defensive against users passing negative caps; cheap (~1ns) and removes a footgun. Defensive idioms at the API boundary are worth their cost. -
clamp(-cap, cap)instead of explicit min/max. Rust's built-in.clampis shorter, more idiomatic, and less error-prone thanraw.max(-cap).min(cap). Use stdlib APIs when they fit; reach for custom code only when they don't. -
No special case for
cap == 0. It falls out of the clamp naturally:clamp(-0, 0)returns0. Edge cases handled naturally are better than edge cases with explicit branches. Explicit branches add code paths to test; natural handling is automatically covered.
Answer key
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
After Lesson 6:
- compute.rs matches Stage 8b through
compute_premium+compute_rate+saturate_i128_to_i64+ the 4 premium tests + 5 rate tests + 1 proptest. The only remaining gap isapply_fundingand the balanced-book proptest (Lesson 7). - lib.rs re-exports
compute_premiumandcompute_rate.apply_fundingis Lesson 7's addition.
Return:
git checkout main
Common questions
Q: Why is params.divisor a u32 if we have to widen it to i64 anyway?
The widening is a single i64::from(u32) call — a no-op cost in machine code. The benefit of u32 storage is bit cost (FundingParams is Copy, smaller is better) and semantic clarity (a divisor of -1 or u64::MAX makes no sense; u32::MAX is still ~4 billion, plenty of headroom). u32 documents intent: "this is a small positive count."
Q: Could compute_rate ever overflow?
The division premium / divisor cannot grow the value — division by a positive integer produces a smaller magnitude. clamp(-cap, cap) cannot grow beyond cap's i64 value. No overflow possible inside compute_rate. Unlike compute_premium, no i128 intermediate is needed.
Q: What if rate_cap > i64::MAX / 2? Does the symmetric clamp still work?
.abs() on i64::MIN panics, and the reason is two's-complement asymmetry: a signed 64-bit integer packs one more negative value than positive (negatives go down to i64::MIN = -2^63, positives only up to i64::MAX = 2^63 - 1), so |i64::MIN| = 2^63 is one bit past the largest representable positive i64. The .abs() call therefore overflows — panic in debug, wrap in release. With rate_cap.0 == i64::MIN, that's the path we'd hit. Stage 8b doesn't guard against this — it's a user-supplied FundingParams issue. Realistic deployments use values like 40_000_000 (way below i64::MAX / 2), so the edge isn't reachable in practice. A defensive saturating_abs() (which folds i64::MIN to i64::MAX) would handle this, but Stage 8b doesn't bother.
In production, this is usually blocked earlier: governance/config ingestion validates rate_cap bounds (e.g. 0..=40_000_000) before parameters reach the pure compute path. That's the defense-in-depth layer above this function.
Q: Why no proptest for compute_rate?
There's no obvious algebraic property to test. "Divide and clamp" doesn't have an antisymmetry, commutativity, or other invariant that proptest would shine on. The 5 hand-traced tests cover the input regions (normal divide, positive clamp, negative clamp, divisor zero, cap zero) well. Proptest is great for properties; hand-traced tests are great for distinct input regions. Don't force a proptest where there's no property to test.
Next lesson (Lesson 7)
Lesson 7 adds apply_funding — the third and final pure function. It takes a slice of Positions, a MarkPrice, and a FundingRate, and returns a Vec<Settlement> (one per non-flat position). The function is ~25 lines but encodes the longs-pay-shorts sign convention and includes the balanced-book zero-sum proptest — for every set of equal-and-opposite positions, the settlement deltas sum to zero (funding redistributes; it doesn't create or destroy quote currency). This is the second proptest in the crate and closes Module 2.
Summary (3 lines)
compute_rate(premium, divisor, cap) -> FundingRate = clamp(premium / divisor, -cap, +cap). Three lines, integer + clamp.- Hyperliquid params: divisor 8, cap ±4 %. Divisor → small frequent rates; cap → worst-case bound on per-interval funding.
- Tests cover both regimes (below cap / above cap). Proptest asserts |rate| ≤ cap. Next: apply_funding + zero-sum proptest.