FABRKNT
Build OpenHL Funding — perpetual funding state machine
Determinism + types
Lesson 4 of 12·CONTENT35 min70 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Build OpenHL Funding — perpetual funding state machine
Lesson role
CONTENT
Sequence
4 / 12

Lesson 3 — Position types — finishing the roster + HL defaults

Question

A position is (size, entry_price, collateral) plus a few derived things. 9 newtypes total to fully describe a Hyperliquid position. Each has a default value (from chainspec) and a saturating-arithmetic envelope.

Principle (minimum model)

  • 9 newtypes. Price / Premium / Notional (from L2) + PositionSize (signed i128) / Collateral / Equity / Pnl / FundingRate (scaled by RATE_SCALE) / FundingPayment.
  • Hyperliquid defaults. Initial margin = 10 % (1000 scaled bps); maintenance = 2 %; max leverage = 50× depending on tier. All in the chainspec; loaded at startup.
  • PositionSize is signed. + long, short. i128 for range; same RATE_SCALE not needed (positions are whole-unit).
  • Derived types. Notional = |size| × mark (typed multiplication). Equity = collateral + unrealized_pnl (typed addition). MarginRatio = collateral / notional (typed division).
  • Compiler-enforced unit math. impl Mul<Price> for PositionSize returns Notional. impl Add<Pnl> for Collateral returns Equity. All others are type errors.
  • Constructors validate. PositionSize::new(0) is allowed (no-position state); Collateral::new(-1) returns Err (collateral cannot be negative).
  • Saturating arithmetic everywhere. Same as L1; consensus-safe.

Worked example + steps

Lesson 3 — Position types — finishing the roster + HL defaults

Goal

Concepts you'll grasp in this lesson:

  • Same shape, different role = separate typesFundingRate and Premium are both i64 scaled by RATE_SCALE, but a premium is the raw dislocation and a rate is the post-divisor-post-clamp output. Keeping them distinct enforces the pipeline at the type level: you can't apply_funding a premium that hasn't been through compute_rate.
  • Single signed integer for direction + magnitudePositionSize(i64) encodes long / short / flat in one field instead of an enum + magnitude pair. Smaller, faster, simpler math; the sign convention lives in the doc comment.
  • Snapshot types vs stateful entitiesPosition carries only (account, size), deliberately omitting entry price, PnL, history. The owning layer (the upstream layer that owns and mutates positions — typically a vault or clearing layer) keeps wide state; the funding crate processes narrow snapshots. The doc comment makes the ownership contract explicit. We'll use "owning layer" consistently in that sense throughout this lesson.
  • Parameter-object pattern — bundling interval_secs, rate_cap, divisor into FundingParams preserves call-site stability when config grows. Positional args break every call site at every new parameter; a struct adds fields without touching signatures.
  • HL-default arithmetic decodeddivisor: 8 means "premium / 8 per tick"; with 24 hourly intervals and a 4% cap, the worst-case daily payment is bounded by the cap (not the divisor). The cap is the insurance policy against oracle dislocations.

Verification:

cargo build -p openhl-funding

…still compiles, with zero rustdoc warnings.

Specific changes:

types.rs is complete — all nine types from Stage 8b's roster are in place:

  • FundingRate(pub i64) — per-interval rate after divisor + cap. Same scale as Premium.
  • PositionSize(pub i64) — signed: positive = long, negative = short, zero = flat.
  • Position { account, size } — per-account snapshot. Activates the openhl_clob::AccountId dependency.
  • Settlement { account, delta } — output of apply_funding: who pays/receives, how much.
  • FundingParams { interval_secs, rate_cap, divisor } + hyperliquid_default() — network-level configuration with HL-shape defaults.

This closes Module 1. After Lesson 3:

  • All types defined; no behavior yet.
  • Cross-references resolve in rustdoc (no more "unresolved link" warnings).
  • The crate is a pure data-types library — useful as documentation, not yet doing math.

Module 2 (Lessons 4–7) starts the pure computecompute_premium, compute_rate, apply_funding. The first tests live there.

The teaching focus this lesson is the parameter-object pattern and the HL-default rationale. Why bundle three parameters into a FundingParams struct instead of passing them as positional args? Why 1-hour interval, why 4% cap, why divisor of 8?

Recap

After Lesson 2:

  • 4 money newtypes (MarkPrice, IndexPrice, Premium, Notional) defined.
  • types.rs has module doc + RATE_SCALE + 4 types.
  • lib.rs re-exports 5 names (the constant + 4 types).
  • 2 unresolved rustdoc warnings remain (FundingRate, FundingClock).

Lesson 3 adds 5 more types (closing the type roster) + the openhl_clob::AccountId import.

Plan

Three edits:

  1. crates/funding/src/types.rs — add the openhl_clob::AccountId import at the top, then append 5 type definitions (FundingRate, PositionSize, Position, Settlement, FundingParams + hyperliquid_default).
  2. crates/funding/src/lib.rs — extend the re-export to include all 9 names.
  3. Verify: cargo build -p openhl-funding compiles with zero warnings.

(Answer: Parameter-object pattern preserves call-site stability across config evolution. compute_rate(premium, params) is one positional arg + one struct. If we later add min_settlement_threshold to the funding config, the function signature stays compute_rate(premium, params) — only the FundingParams struct grows. Positional-arg variants compute_rate(premium, interval, cap, divisor) would break every call site at every new parameter. With <5 call sites today (clock + tests) the cost is modest; with 50+ in a mature codebase, the parameter object is essential. Bundle stable groupings of values together when the grouping itself is a domain concept — "the funding configuration" is one such concept.)

Walk-through

Step 1: Add the AccountId import

At the top of crates/funding/src/types.rs, after the module doc but before pub const RATE_SCALE, add:

use openhl_clob::AccountId;

This import was set up in Lesson 1's Cargo.toml (the openhl-clob = { path = "../clob" } dep). It activates here because Position and Settlement will reference AccountId as a struct field type.

Step 2: Append FundingRate after Premium

After the existing Premium definition, add:

/// Per-interval funding rate. Same scale as [`Premium`]; positive means
/// longs pay shorts. A rate of `RATE_SCALE / 100` = 1% per interval.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FundingRate(pub i64);

FundingRate is structurally identical to Premium — same i64, same derives. The reason it's a separate type, not an alias, is that they represent different concepts in the funding pipeline. A premium is the raw mark/index dislocation; a rate is what gets applied to positions after divisor + clamp. Code that consumes a premium (compute_rate) shouldn't accept a rate (which is post-processed); code that consumes a rate (apply_funding) shouldn't accept a premium (which hasn't been clamped).

Same shape, different roles, separate types. This is the newtype pattern doing exactly what it does for MarkPrice vs IndexPrice.

Step 3: Append PositionSize

After FundingRate:

/// Signed position size in base units. Positive = long, negative = short,
/// zero = flat. Accounts with zero size aren't included in settlement
/// snapshots — see [`Position`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PositionSize(pub i64);

One signed integer carries three states: long (> 0), short (< 0), flat (== 0). Compare to a 2-field representation:

// Verbose alternative — NOT what we use:
pub struct PositionSize {
    pub direction: Direction,  // Long, Short, Flat
    pub magnitude: u64,
}

The signed-integer representation is smaller (8 bytes vs ~16+), faster (no enum dispatch in the hot path), and simpler at the math layer (just multiply by size.0; the sign carries through naturally). The tradeoff: the inner value's sign is implicit. The doc comment names it explicitly: "Positive = long, negative = short, zero = flat."

The note "Accounts with zero size aren't included in settlement snapshots" is load-bearing. apply_funding will filter zero-size positions out — they have no economic exposure, so settling them produces a zero delta that adds noise. We'll see that filter in Lesson 7.

Step 4: Append Position

/// A single account's net position on the market. The funding state machine
/// treats positions as a per-tick *snapshot* — it never owns or mutates
/// them. The owning layer (vault / clearing) is responsible for tracking
/// `Position` over time and producing snapshots at each tick.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Position {
    pub account: AccountId,
    pub size: PositionSize,
}

Two fields, both public. account lets the settlement output know whose balance to credit/debit. size lets the rate-application math compute the delta.

Crucially: no entry_price, no realized_pnl, no unrealized_pnl. The funding state machine doesn't need to know how the position was opened or what its P&L looks like — it just needs the current size to multiply against the current rate. The simpler the snapshot, the easier it is to produce one upstream.

The doc comment makes the ownership boundary explicit: "never owns or mutates them. The owning layer is responsible..." — this is the contract between the funding crate and its callers.

No Default on PositionAccountId::default() would be AccountId(0), which is reserved/sentinel in most account systems. Don't accidentally allow default-construction of an entity-identity-bearing struct.

Step 5: Append Settlement

/// Output of applying a funding rate to one position. The bridge layer
/// translates these into balance updates against each account's quote
/// balance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Settlement {
    pub account: AccountId,
    pub delta: Notional,
}

Settlement is the output type of apply_funding: one per non-flat position. It carries the account ID (so the bridge knows who) and the delta (so the bridge knows how much).

Why does Settlement carry account again instead of being indexed by position order? Because apply_funding filters zero-size positions out, the input position list and the output settlement list have different lengths. Indexing by position would require the caller to remember which positions had nonzero size; carrying the account ID in the output decouples them.

This is the parallel-array vs struct-array tradeoff — and Stage 8b chose struct-array. The cost is one redundant AccountId per settlement; the benefit is callers don't need to maintain index correspondence.

Step 6: Append FundingParams + hyperliquid_default

/// Network parameters that govern funding cadence and magnitude.
///
/// `divisor` represents "settlements per day": HL settles 8 times per day,
/// so `premium / 8` is the per-interval rate. Higher divisor → smaller rate
/// per tick (and inverse: lower divisor concentrates the same daily target
/// rate into fewer payments).
///
/// `rate_cap` is the absolute maximum |rate| per interval. Production
/// networks set this to bound the worst-case payment an extreme oracle
/// dislocation can produce. Zero `rate_cap` disables funding entirely.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FundingParams {
    pub interval_secs: u64,
    pub rate_cap: FundingRate,
    pub divisor: u32,
}

impl FundingParams {
    /// Hyperliquid-style defaults: 1-hour interval, ±4%/hour cap, 8× divisor.
    /// 8× divisor with a 1-hour interval means the *target* daily premium
    /// would be applied across 24 hours' worth of ticks at 1/8 of the premium
    /// each — i.e., 24/8 = 3× the premium per day. That asymmetry is
    /// intentional: HL caps more aggressively than the divisor alone implies.
    #[must_use]
    pub const fn hyperliquid_default() -> Self {
        Self {
            interval_secs: 3600,
            // 4% per interval = 40_000_000 ppb (since 0.04 × 1e9 = 4e7).
            rate_cap: FundingRate(40_000_000),
            divisor: 8,
        }
    }
}

Three fields, all pub for the same reason as the newtypes — compute_rate needs them all directly.

Why each HL default

  • interval_secs: 3600 — 1 hour. HL settles every hour; Binance Futures settles every 8 hours. The 1-hour cadence is short enough that traders feel funding pressure quickly when basis dislocates, long enough that block-time noise doesn't dominate.
  • rate_cap: FundingRate(40_000_000) — 4%/interval. With 24 intervals/day this is ±96%/day worst case; with the divisor below, effective worst is much lower. The cap is the insurance policy against oracle hijinks: an attacker who can move the index 50% transiently can't extract 50% from the longs in one tick.
  • divisor: 8 — 8 settlements per day (per HL's spec), but applied across 24 1-hour intervals. The arithmetic in the doc comment is the load-bearing nuance: (premium / 8) × 24 hours = 3 × premium/day. HL's caps are stricter than the divisor alone implies — the divisor sets the cadence, but the cap binds the worst-case payment.

The asymmetry between "divisor = 8" and "applied 24 times/day" is the thing to internalize. Laying it out side by side:

              Semantic intent                  What actually happens
              ─────────────────                ─────────────────────
divisor = 8 = "split the day into 8"           But we settle/apply every hour (24 / day)
                ↓                                ↓
If those two had matched:                       Actual per-day accumulation:
  premium / 8 × 8 = premium                     premium / 8 × 24 = 3 × premium
  → one premium's worth of funding paid daily   → 3× the "targeted daily" amount

Then the cap (4%/interval) steps in:
  In normal markets the post-divisor rate is ≪ 4%, so the cap never bites,
  and effective daily ≈ 3 × premium.
  In pathological markets (oracle outage etc.), each hour clamps at 4%, so the
  worst case is bounded at 4% × 24 = 96%/day.

So HL runs a two-stage, asymmetric design: the divisor lifts the typical daily payment to ~3 × premium, and the cap chops worst-case daily down to 96%. The cap supplies a tighter absolute ceiling than the divisor's "8 settlements/day" semantics would naively predict.

(Answer: ±96%/day if every interval hits the cap. The cap of 4% per interval applies regardless of the divisor. The divisor only affects the per-interval rate before clamping. So if the premium is so large that the post-divisor rate exceeds 4%, every hour clamps to 4%, and 24 hourly clamps × 4% = 96% per day. In practice, premiums that drive sustained 4%/interval clamping are pathological — HL has historically seen them only during oracle outages. The cap is the floor on insurance cost, not the typical funding magnitude.)

Why const fn on hyperliquid_default

const fn lets us write static DEFAULT: FundingParams = FundingParams::hyperliquid_default(); if we ever want a compile-time constant. The cost is zero (it's a no-arg constructor of constants); the benefit is preserving the option.

Why #[must_use]

#[must_use] triggers a warning if a caller invokes hyperliquid_default() and discards the result. For a function whose entire purpose is to produce a value, discarding the result is always a bug — the warning catches a class of "I forgot to assign" mistakes.

Step 7: Update lib.rs re-exports

The current re-export:

pub use types::{IndexPrice, MarkPrice, Notional, Premium, RATE_SCALE};

Replace with the complete list:

pub use types::{
    FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
    Premium, Settlement, RATE_SCALE,
};

Alphabetical order maintained. 10 names total (9 types + RATE_SCALE). Callers can now write use openhl_funding::{FundingParams, Position}; etc. without going through the types module.

Step 8: Compile

cargo build -p openhl-funding

Expected output:

   Compiling openhl-funding v0.1.0 (/Users/.../my-openhl/crates/funding)
warning: unresolved link to `FundingClock`
    Finished `dev` profile [unoptimized + debuginfo] in 0.4s

One rustdoc warning remaining (from Lesson 0 we had 3; Lesson 1 still 3; Lesson 2 had 2; Lesson 3 has 1). The last unresolved link is FundingClock — resolved by Lesson 8.

Actually — depending on rustdoc's link-resolution behavior, the [FundingRate] and [Premium] cross-refs in the various doc comments may all resolve now (those types now exist). Verify with cargo doc -p openhl-funding --no-deps. The exact warning count may differ.

Common errors:

  • error[E0432]: unresolved import 'openhl_clob::AccountId' — Cargo.toml dep not in place. Re-check Lesson 1's [dependencies] block has openhl-clob = { path = "../clob" }.
  • error: cannot find type 'Notional' in this scope in Settlement — you didn't import the local type. Notional is in the same module, no use needed, but the type name must be spelled exactly.
  • error: function calls are not allowed in const fn on hyperliquid_default — you wrote FundingRate::from(40_000_000) or similar. Use the tuple-struct literal FundingRate(40_000_000) directly.

Design reflection

Four load-bearing decisions in this lesson:

  1. FundingRate is a separate type from Premium despite identical shape. The newtype pattern enforces the pipeline stages — a premium can't be applied to positions without going through compute_rate first. Same-shape-but-different-role is the canonical newtype use case.

  2. PositionSize is a single signed integer, not direction + magnitude. Smaller, faster, simpler math — and the doc comment is the contract for the sign convention. Choose the densest representation that the math will use anyway.

  3. Position is a snapshot type, not a stateful entity. No entry price, no PnL, no history — just (account, size). The owning layer tracks state; the funding crate processes snapshots. Narrow downstream types; wide upstream types.

  4. FundingParams bundles config that varies as a unit. Three values that always travel together; expanding the bundle later doesn't break call sites. Parameter object whenever the grouping is itself a domain concept.

Answer key

cd ~/code/openhl-reference
git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/types.rs ./crates/funding/src/types.rs
diff -u ~/code/my-openhl/crates/funding/src/lib.rs ./crates/funding/src/lib.rs

After Lesson 3:

  • types.rs matches Stage 8b completely — all 9 types + RATE_SCALE + hyperliquid_default.
  • lib.rs has the full type re-export; only the compute / clock re-exports are missing.

Module 1 is complete. From Lesson 4 onward we shift to compute.rs — pure functions over these types, with tests.

Return:

git checkout main

Common questions

Q: Why is FundingParams::divisor a u32 and not u64? HL's divisor is 8. Other configurations might go to 24 (once-per-hour-as-the-divisor) or 1 (single daily settlement). Even pathological values stay well under u32::MAX (~4 billion). u32 is "more than enough" with half the bit cost of u64 — and compute_rate will widen to i64 for the division anyway. Tiny optimization, but Copy types benefit.

Q: Should FundingParams validate its fields in a constructor? Tempting — reject interval_secs == 0 (would cause division-by-zero or perpetual gating)? Reject divisor == 0? Stage 8b chose not to: validation in a constructor means there's another validation point besides the caller's input handling, and divergence between the two becomes a bug source. Single point of truth for input validation: the caller. That said, compute_rate does handle divisor == 0 as "funding disabled" — a defensive default, not a validation.

Q: Why does Position derive Eq but not Default? Eq because positions are compared in tests (and possibly in some upstream dedup logic). Default would give Position { account: AccountId(0), size: PositionSize(0) }, which is nonsensical (AccountId(0) is typically a sentinel). Defaults should produce sensible values; if they can't, omit the derive.

Q: Are the Position and Settlement types redundant — they both have account + a value field? They look similar but they're at different lifecycle stages. Position is an input to apply_funding; Settlement is its output. The owning layer hands you Positions and receives Settlements back. Type-level distinction prevents accidentally re-applying settlements as if they were positions.

The data pipeline cutting through Module 1

The 9 types defined so far are exactly the vocabulary of the pure-compute pipeline that Module 2 (Lessons 4–7) will assemble. The whole arrangement on one page:

[Inputs (snapshots)]                [Pure compute (Module 2)]              [Outputs]

  MarkPrice  ──┐
               ├─► (Lesson 4: compute_premium) ─► Premium ──┐
  IndexPrice ──┘                                       │
                                                       ▼
  FundingParams ───────────────────────────► (Lesson 6: compute_rate)
   { rate_cap, divisor, … }                            │
                                                       ▼
                                                  FundingRate ──┐
                                                                ├─► (Lesson 7: apply_funding) ──► Vec<Settlement>
  Position (snapshot)             ──────────────────────────────┘                            { account, delta: Notional }
   { account, size: PositionSize }

Three properties this picture enforces at the type level:

  1. Premium and FundingRate share i64 but are different types — handing a Premium straight to apply_funding without going through compute_rate is a compile error. The pipeline order is policed by the type system.
  2. Inputs (Position) and outputs (Settlement) are separate types — the owning layer can't accidentally feed a Settlement back as if it were a position; the types block that path.
  3. FundingParams runs alongside as a side-branch — it's a config argument referenced by each settlement computation, not a value on the main pipeline. Adding min_settlement_threshold later doesn't grow the number of arrows.

Module 2 fills in the three functions in this diagram, in order. Every argument and return value will use only the types defined in Lessons 1–3.

Module 1 milestone — what you've built

After Lesson 3 you have:

  • 9 newtypes + 1 struct-with-method (FundingParams).
  • ~110 lines of types.rs matching Stage 8b exactly.
  • A full vocabulary for talking about funding — every value in the math pipeline (premium, rate, settlement, position) has a type.
  • Zero behavior yet. Modules 2-3 add the behavior.

Next lesson (Lesson 4)

Lesson 4 starts compute.rs. We create the file with the module doc + compute_premium function — the first math in the crate. The function is 8 lines but encodes 3 design decisions: (a) handle index == 0 by returning Premium(0) instead of erroring; (b) use i128 intermediates to avoid overflow on the subtraction-times-scale; (c) saturate back to i64 rather than wrapping. The lesson also adds the first 4 unit tests — premium-zero-when-equal, premium-positive/negative cases, and the index == 0 saturation test. First tests in the crate.

Summary (3 lines)

  • 9 newtypes complete the position roster (Price / Premium / Notional / PositionSize / Collateral / Equity / Pnl / FundingRate / FundingPayment).
  • Hyperliquid defaults loaded from chainspec (initial 10 % / maintenance 2 % / max leverage 50×).
  • Type-correct arithmetic (size * price = notional, collateral + pnl = equity). Saturating throughout. Next module: pure compute.