レッスン8 — FundingClock — discrete event loop
問い
3 つの pure 関数(premium/rate/funding)は揃ったが、いつ 呼ぶかを誰も決めていない。funding は固定 interval(HL は 1h)ごとに 1 回だけ settle すべき。pure な数学を、determinism を失わずに正しい cadence で gate するには?
原理(最小モデル)
- pure 関数の上に discrete event loop を載せる。 clock の仕事は「正しいタイミングで数学を呼ぶ/間違ったタイミングでは呼ばない」の 2 つだけ。数学はそのまま、clock は いつ を足すだけで 何を には手を入れない。
- 常に値を返さず
Option<FundingTick>を返す。Noneだけで「state 変化なし」を安価に伝える(if let Some(tick) = clock.tick(...))。常に返すと「fire したが position なし」と「そもそも fire してない」が区別できない。 - レイヤード composition、再実装しない。
tick()はcompute_premium → compute_rate → apply_fundingを順に呼ぶだけ。数学が計算、clock が gate。 - テレメトリのために中間値を出力に出す。
FundingTickにsettlementsだけでなくpremium/rateも載せる(observer が再計算せず読める — 再計算は乖離の温床)。 - 契約上シングルスレッド。 並行性は呼び出し側の責任。
AtomicU64にすると、このレイヤーに存在しない直列化問題に複雑性を足すだけ。
具体例
tick のボディは 3 phase が時間制御 → 純粋計算 → state 更新の順に重なる:
1. Guard (時間制御) if now < last_settled_at + interval_secs → return None
│ (満たす場合のみ下へ)
2. Compute (ステートレス) (mark,index)→compute_premium→Premium
(premium,params)→compute_rate→FundingRate
(positions,mark,rate)→apply_funding→Vec<Settlement>
│
3. State更新+Return self.last_settled_at = now; ← deadline をリセット
return Some(FundingTick{ settled_at:now, premium, rate, settlements })
clock が時間を gate し、セクション2 の数学が値を計算し、出力レイヤーが state を進めて返す。決定的に重要なのは clock を now に進めること(last_settled + interval でなく)= no-catch-up 不変条件の実装(理由はレッスン10)。
失敗例(誤解)
「並行 tick のため last_settled_at を AtomicU64 に」は誤り — funding crate は契約として single-threaded。並行 tick は last_settled_at だけでなく CLOB_STATE/balance store でも race を起こす。正解は呼び出し側で tick を直列化すること。並行性をデータ構造に押し込むと、本来存在すべきでない問題に複雑性を足す。
ここまでで「clock は pure 数学を gate する薄いレイヤー」は着地した。ここから 3 つ目で最後のモジュールを作る(不変条件 interval-once / no-catch-up はレッスン9/10 が受け持つ、ここは土台)。コードは完全形。
🛑 予測。
tick()はOption<FundingTick>を返す。なぜ常にFundingTick(settlement なしは空 Vec)を返さない?(答え:Noneだけで「state 変化なし」を通知でき呼び出し側が inspect 不要。if let Some(tick)が自然。常に返すとif !tick.settlements.is_empty()が要るが、空 settlement は「fire したが position なし」か「そもそも fire してない」か区別できない。Optionがこの二分を型で明示。)
ステップで組み立てる
Step 1: clock.rs を作成
//! Funding clock — the gating state machine that decides *when* to settle.
//!
//! The rate math lives in [`crate::compute`]; this module is the discrete
//! event loop that calls it on the right cadence. Two invariants:
//!
//! 1. **At most one settlement per interval.** Two ticks at the same
//! timestamp produce one settlement, not two.
//! 2. **No catch-up.** If `now` jumps forward by 10 intervals (validator
//! reboot, chain pause), we settle *once*. Compounding 10 ticks of
//! retroactive funding from a single stale snapshot would over-pay
//! whichever side has been losing without giving the loser a chance
//! to close. Production deployments that need catch-up logic should
//! build it on top of repeated ticks with fresh snapshots, not here.
use crate::compute::{apply_funding, compute_premium, compute_rate};
use crate::types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Position, Premium, Settlement,
};
module doc が 2 不変条件をコードより先に宣言(契約を約束し、下のコードとテストで守る)。imports は必要なものを一通り(boilerplate を早めに安定化)。
Step 2: FundingClock 構造体
/// State that persists across funding ticks. The clock is initialized with
/// the timestamp of its last settlement (often the chain's genesis time, or
/// the previous validator-set's last tick).
#[derive(Clone, Debug)]
pub struct FundingClock {
params: FundingParams,
last_settled_at: u64,
}
2 private フィールド: params(construction 後 immutable — production が稼働中に funding params を変えない)/ last_settled_at(唯一の可変 state)。Clone, Debug のみ derive — Copy を付けない(気軽に複製できると「どのコピーが advance しているか」を見失う)。
Step 3: FundingTick
/// The output of a successful tick. Returned by [`FundingClock::tick`] when
/// at least `params.interval_secs` have elapsed since the last settlement.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FundingTick {
pub settled_at: u64,
pub premium: Premium,
pub rate: FundingRate,
pub settlements: Vec<Settlement>,
}
4 pub フィールド(出力 struct は plain data なので全 public)。bridge が必要なのは settlements だけだが premium/rate も載せる(telemetry — ないと observer が再計算して乖離リスク)。PartialEq, Eq はテスト容易性。
Step 4: impl ブロック
impl FundingClock {
/// Construct a clock that thinks its last settlement happened at
/// `genesis_time`. The first tick after `genesis_time + interval_secs`
/// will fire.
#[must_use]
pub const fn new(params: FundingParams, genesis_time: u64) -> Self {
Self {
params,
last_settled_at: genesis_time,
}
}
#[must_use]
pub const fn params(&self) -> FundingParams {
self.params
}
#[must_use]
pub const fn last_settled_at(&self) -> u64 {
self.last_settled_at
}
/// Attempt a settlement. Returns `Some` only if at least one full
/// `interval_secs` has elapsed since `last_settled_at`.
///
/// On success, the clock advances to `now` (NOT to
/// `last_settled_at + interval`) — see the "no catch-up" invariant in
/// the module docs. Production callers wanting strict interval alignment
/// can advance externally, but openhl's default is "settle on the first
/// block ≥ interval boundary, then reset the deadline".
pub fn tick(
&mut self,
now: u64,
mark: MarkPrice,
index: IndexPrice,
positions: &[Position],
) -> Option<FundingTick> {
if now < self.last_settled_at.saturating_add(self.params.interval_secs) {
return None;
}
let premium = compute_premium(mark, index);
let rate = compute_rate(premium, self.params);
let settlements = apply_funding(positions, mark, rate);
self.last_settled_at = now;
Some(FundingTick {
settled_at: now,
premium,
rate,
settlements,
})
}
}
new/accessor は const fn+#[must_use](FundingParams: Copy なので値で返す)。tick の guard は saturating_add(last_settled_at が u64::MAX 近くでも overflow 防止)。last_settled_at = now(+interval でなく)が no-catch-up の実装。timestamp は Unix 秒(+3600 = 1 時間)。
Step 5: 3 サニティテスト
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Notional, PositionSize};
use openhl_clob::AccountId;
fn pos(account: u64, size: i64) -> Position {
Position {
account: AccountId(account),
size: PositionSize(size),
}
}
fn balanced_book() -> Vec<Position> {
vec![pos(1, 100), pos(2, -100)]
}
#[test]
fn first_tick_before_interval_returns_none() {
let params = FundingParams::hyperliquid_default(); // 3600s interval
let mut clock = FundingClock::new(params, 1_000_000);
// 3599 seconds later — not enough.
let out = clock.tick(1_003_599, MarkPrice(100), IndexPrice(100), &balanced_book());
assert!(out.is_none());
// Clock didn't advance.
assert_eq!(clock.last_settled_at(), 1_000_000);
}
#[test]
fn first_tick_at_exact_interval_fires() {
let params = FundingParams::hyperliquid_default();
let mut clock = FundingClock::new(params, 1_000_000);
let out = clock
.tick(1_003_600, MarkPrice(100), IndexPrice(100), &balanced_book())
.expect("tick should fire at exact interval boundary");
assert_eq!(out.settled_at, 1_003_600);
// mark == index → zero rate → empty settlements
assert_eq!(out.rate, FundingRate(0));
assert!(out.settlements.is_empty());
assert_eq!(clock.last_settled_at(), 1_003_600);
}
#[test]
fn empty_positions_yield_empty_settlements_but_still_advance_clock() {
let params = FundingParams::hyperliquid_default();
let mut clock = FundingClock::new(params, 1_000_000);
let out = clock
.tick(1_003_600, MarkPrice(101), IndexPrice(100), &[])
.expect("tick fires regardless of position count");
assert!(out.settlements.is_empty());
// But the rate was still computed — useful for telemetry.
assert_eq!(out.rate, FundingRate(1_250_000));
assert_eq!(clock.last_settled_at(), 1_003_600);
}
}
Notional/PositionSize import と pos/balanced_book helper は今後のテストで使うため安定化。3 つ: guard 動作(interval 前は None・state 不変)/ 境界 inclusive(genesis+interval ちょうどで fire、math composition も検証)/ 空 positions でも advance(position の有無で gate しない)。
Step 6: lib.rs を更新(最終形)
pub mod clock;
pub mod compute;
pub mod types;
pub use clock::{FundingClock, FundingTick};
pub use compute::{apply_funding, compute_premium, compute_rate};
pub use types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
Premium, Settlement, RATE_SCALE,
};
アルファベット順。これが lib.rs の最終形(レッスン9/10 で新しいモジュールレベルの名前は追加しない)。
Step 7: テスト実行
cargo test -p openhl-funding が 18 pass(compute 15 + clock 3)、rustdoc warning ゼロ(FundingClock リンクが解決)。
答え合わせ
cd ~/code/openhl-reference && git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/clock.rs ./crates/funding/src/clock.rs
diff -u ~/code/my-openhl/crates/funding/src/lib.rs ./crates/funding/src/lib.rs
git checkout main
clock.rs は FundingClock + FundingTick + impl + 7 テスト中 3 つまで一致(残り 4 つはレッスン9/10)。lib.rs は cd94137 と完全一致(最終形)。
合格基準
cargo test -p openhl-funding が 18 pass。よくあるエラー: guard の < を <= に(境界がずれる)/ self.last_settled_at = now 忘れで即再 fire / clock.tick(...).expect(...) の戻り値をチェーンしたまま last_settled_at() を呼ぶと borrow checker エラー(let out = ...; で一度束縛して &mut 借用を ; で切る)。
まとめ(3行)
FundingClockは pure な数学を正しい cadence で gate する discrete event loop。tick()は guard →compute_premium/compute_rate/apply_fundingの compose → state 更新の 3 phase。Option<FundingTick>を返す(None= state 変化なしを型で明示)。FundingTickは telemetry のためpremium/rateも載せる。single-threaded 契約。- 成功 tick で
last_settled_at = now(+intervalでなく)= no-catch-up。module doc が 2 不変条件を先に宣言。lib.rs はこれで最終形、warning ゼロ。
次のレッスン(レッスン9)
clock.rs にテスト 3 つを追加し interval-gating 不変条件を深掘り(premium_drives_settlement_signs の full composition / second_tick_requires_another_full_interval の持続性 / capped_rate_when_premium_extreme の cap surfacing)。新規 production コードなし。