レッスン3 — Position 型 — roster 完成 + HL デフォルト
問い
compute_rate は interval_secs/rate_cap/divisor の 3 値が要る。positional 引数で渡すと、後で 4 つ目を足したとき全呼び出し箇所が壊れる。config の進化をまたいで呼び出し箇所を安定させるには? そして HL の「divisor 8 / 4% cap / 1h」は何を encode しているか?
原理(最小モデル)
- 同じ形・別の役割 = 別の型。
FundingRateもPremiumもRATE_SCALEスケールのi64だが、premium=生の dislocation、rate=divisor+clamp 後の出力。別型なら pipeline を型で強制(compute_rateを通さない premium をapply_fundingに渡せない)。 - 方向+大きさを符号付き整数 1 つで。
PositionSize(i64):正=long / 負=short / 0=flat。enum+magnitude より小さく・速く・数学が単純。符号規約は doc に。 - スナップショット型 vs stateful エンティティ。
Positionは(account, size)だけ、entry price も PnL も履歴も持たない。広い state は owning layer(vault/clearing)の仕事、funding crate は狭い snapshot を処理する。 - parameter-object パターン。 3 値を
FundingParamsにまとめれば config 拡張で呼び出し箇所が壊れない(成長するのは struct だけ)。グループ自体がドメイン概念のときに使う。 - HL デフォルトの非対称 2 段構え。 divisor=8 で typical daily を 3×premium に持ち上げ、cap=4%/interval で worst daily を 96% に切る。
具体例
セマンティクス上の意図 実際の挙動
───────────────────── ───────────────
divisor = 8 = 「1 日を 8 分割」 でも settle/適用は毎時 (24 回/日)
↓ ↓
premium / 8 × 8 = premium premium / 8 × 24 = 3 × premium
→ 1 日分の premium がそのまま → 「狙った daily 量」より 3 倍
そこで cap (4%/interval):
普通の市場では post-divisor rate ≪ 4% で cap に当たらず、実効 daily ≒ 3 × premium。
異常時 (oracle outage 等) でも 毎時 4% で clamp → 最悪 daily = 4% × 24 = 96%/day で必ず止まる。
HL は「divisor で typical daily を 3×premium に持ち上げ、cap で worst daily を 96% に切る」非対称 2 段構え。divisor 単体(1 日 8 settlement)から素直に出る値より、cap のほうが厳しい絶対上限を提供する。
失敗例(誤解)
「Position も先物の損益計算のため entry price を持つべき」は誤り — それは owning layer の仕事。vault/clearing が entry price を追跡し PnL を計算する。funding crate はその下流で、現在 の position snapshot に 現在 の funding を適用するだけ。snapshot 型は narrow に保ち、owning layer が wide な型を持てばいい。
ここまでで「pipeline を型で強制」「parameter object」「HL デフォルト」は着地した。ここから 5 型を足して roster を閉じ、AccountId import も入れる(これで セクション1 完了、rustdoc warning もゼロへ)。コードは完全形。
🛑 予測。
FundingParams { interval_secs, rate_cap, divisor }を struct にまとめる理由は(compute_rate(premium, interval, cap, divisor)でなく)?(答え: parameter-object が config 進化をまたいで呼び出し箇所を安定させる。後でmin_settlement_thresholdを足してもシグネチャはcompute_rate(premium, params)のまま — 成長するのは struct だけ。positional 版は新パラメータごとに全呼び出し箇所が壊れる。安定して一緒に動く値で、グループ自体がドメイン概念のときにまとめる。)
ステップで組み立てる
Step 1: AccountId import を追加
types.rs の module doc の後、RATE_SCALE の前に:
use openhl_clob::AccountId;
レッスン1 の Cargo.toml dep で準備済み。AccountId は自前の型でないので re-export しない(依存先の型は呼び出し側に直接 import させる — re-export すると同じ型に 2 つの import path ができて依存が不透明になる)。
Step 2: Premium の後ろに FundingRate を append
/// 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);
Premium と同形(同 i64・同 derive)だが別型 — premium=生の dislocation、rate=divisor+clamp 後に position へ適用される値。compute_rate を通さない premium を apply_funding に渡せない(型が pipeline 順序を強制)。
Step 3: PositionSize を append
/// 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);
符号付き整数 1 つで long(>0)/short(<0)/flat(==0)。{ direction: Direction, magnitude: u64 } の 2 フィールド表現より小さく(8 vs ~16 byte)・速く(hot path で enum dispatch 不要)・数学が単純(size.0 の乗算で符号が自然に伝播)。「zero size は settlement snapshot に含めない」が load-bearing(apply_funding が filter、レッスン7)。
Step 4: Position を append
/// 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,
}
(account, size) だけ — entry_price/realized_pnl なし(owning layer の仕事)。doc が ownership 契約を明示(「never owns or mutates them」)。Default を付けない — AccountId::default() = AccountId(0) は多くのアカウントシステムで sentinel。identity を担う struct に偶発的なデフォルト構築を許さない。
Step 5: Settlement を append
/// 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,
}
apply_funding の出力、非 flat position 1 つにつき 1 つ。位置インデックスでなく account を再度持つのは、filter で入力 position と出力 settlement の長さが違うから(インデックス対応の管理を呼び出し側から切り離す)。struct-array vs parallel-array のトレードオフで struct-array を選択(コスト = 冗長な AccountId 1 つ、メリット = インデックス管理不要)。
Step 6: FundingParams + hyperliquid_default を append
/// 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,
}
}
}
HL デフォルトの理由: interval_secs: 3600(毎時 — basis dislocation を素早く感じ取れ、block time noise に支配されない)/ rate_cap: FundingRate(40_000_000)(4%/interval、oracle 異常への保険 — index を一時的に 50% 動かせる攻撃者でも 1 tick で 50% を抜けない)/ divisor: 8(1 日 8 settlement だが 24 interval にまたがり適用)。const fn(コンパイル時定数に使える)+ #[must_use](値生成が目的の関数で結果破棄は常にバグ)。
Step 7: lib.rs re-export を更新
pub use types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
Premium, Settlement, RATE_SCALE,
};
アルファベット順、合計 10 名前(9 型 + RATE_SCALE)。
Step 8: コンパイル
cargo build -p openhl-funding
warning: unresolved link to `FundingClock`
Finished `dev` profile [unoptimized + debuginfo] in 0.4s
warning は 1 つに(残るは FundingClock のみ、レッスン8 で解決)。
セクション1 のデータパイプライン
定義した 9 型は、セクション2(レッスン4〜7)で組み立てる純粋計算パイプラインの語彙だ:
MarkPrice ──┐
├─► (レッスン4: compute_premium) ─► Premium ──┐
IndexPrice ──┘ │
▼
FundingParams ───────────────────────────► (レッスン6: compute_rate)
{ rate_cap, divisor, … } │
▼
FundingRate ──┐
├─► (レッスン7: apply_funding) ──► Vec<Settlement>
Position (snapshot) ──────────────────────────────┘ { account, delta: Notional }
{ account, size: PositionSize }
型レベルで強制する 3 点: ① Premium と FundingRate は同 i64 だが別型(compute_rate を通さず apply_funding に渡すとコンパイルエラー、pipeline 順序を型で守る)② 入力 Position と出力 Settlement を別型に(Settlement の再適用を型で塞ぐ)③ FundingParams は枝として並走(後から閾値が増えても矢印は増えない)。
答え合わせ
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
git checkout main
types.rs は cd94137 と 完全一致(9 型 + RATE_SCALE + hyperliquid_default、~110 行)。lib.rs は compute/clock の re-export だけ欠ける。セクション1 完了。
合格基準
cargo build -p openhl-funding が warning ≤1(FundingClock のみ、レッスン8 で解決)。よくあるエラー: E0432(openhl-clob dep 欠け)/ Notional 綴り間違い / const fn 内の関数呼び出し(FundingRate(40_000_000) の tuple-struct リテラルを使う)。
まとめ(3行)
- 9 型で funding の語彙が完成:
FundingRate(Premiumと同形・別型で pipeline 順序を型強制)/PositionSize(符号付き 1 整数で long/short/flat)/Position(narrow な snapshot)/Settlement(出力)/FundingParams。 FundingParamsは parameter-object で config 進化に強い。HL デフォルトは非対称 2 段構え(divisor で typical を 3×premium に、cap で worst を 96%/day に切る)。Positionは entry price/PnL を持たない snapshot(state 追跡は owning layer)。AccountId(0)sentinel 回避でDefaultを付けない。types.rs はcd94137と完全一致。
次のレッスン(レッスン4)
compute.rs を始める — crate 最初の数学 compute_premium(8 行)。設計判断 3 つ(index==0 を Premium(0) で扱う / i128 中間値で overflow 回避 / i64 へ saturate)と、crate 最初の unit test 4 つを追加する。