FABRKNT
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
Clock state machine
レッスン 9 / 12·CONTENT35 分70 XP
コース
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
レッスンの役割
CONTENT
順序
9 / 12

レッスン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。
  • テレメトリのために中間値を出力に出す。 FundingTicksettlements だけでなく 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_atAtomicU64 に」は誤り — 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_addlast_settled_atu64::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 コードなし。