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.
PositionSizeis signed.+long,−short.i128for 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 PositionSizereturnsNotional.impl Add<Pnl> for CollateralreturnsEquity. All others are type errors. - Constructors validate.
PositionSize::new(0)is allowed (no-position state);Collateral::new(-1)returnsErr(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 types —
FundingRateandPremiumare bothi64scaled byRATE_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'tapply_fundinga premium that hasn't been throughcompute_rate. - Single signed integer for direction + magnitude —
PositionSize(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 entities —
Positioncarries 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,divisorintoFundingParamspreserves 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 decoded —
divisor: 8means "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 asPremium.PositionSize(pub i64)— signed: positive = long, negative = short, zero = flat.Position { account, size }— per-account snapshot. Activates theopenhl_clob::AccountIddependency.Settlement { account, delta }— output ofapply_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 compute — compute_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.rshas module doc +RATE_SCALE+ 4 types.lib.rsre-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:
crates/funding/src/types.rs— add theopenhl_clob::AccountIdimport at the top, then append 5 type definitions (FundingRate,PositionSize,Position,Settlement,FundingParams+hyperliquid_default).crates/funding/src/lib.rs— extend the re-export to include all 9 names.- Verify:
cargo build -p openhl-fundingcompiles 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 Position — AccountId::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%/dayworst 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 hasopenhl-clob = { path = "../clob" }.error: cannot find type 'Notional' in this scopeinSettlement— you didn't import the local type.Notionalis in the same module, nouseneeded, but the type name must be spelled exactly.error: function calls are not allowed in const fnonhyperliquid_default— you wroteFundingRate::from(40_000_000)or similar. Use the tuple-struct literalFundingRate(40_000_000)directly.
Design reflection
Four load-bearing decisions in this lesson:
-
FundingRateis a separate type fromPremiumdespite identical shape. The newtype pattern enforces the pipeline stages — a premium can't be applied to positions without going throughcompute_ratefirst. Same-shape-but-different-role is the canonical newtype use case. -
PositionSizeis 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. -
Positionis 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. -
FundingParamsbundles 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/clockre-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:
PremiumandFundingRatesharei64but are different types — handing aPremiumstraight toapply_fundingwithout going throughcompute_rateis a compile error. The pipeline order is policed by the type system.- Inputs (
Position) and outputs (Settlement) are separate types — the owning layer can't accidentally feed aSettlementback as if it were a position; the types block that path. FundingParamsruns alongside as a side-branch — it's a config argument referenced by each settlement computation, not a value on the main pipeline. Addingmin_settlement_thresholdlater 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.rsmatching 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.