FABRKNT
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
純粋な compute
レッスン 8 / 12·CONTENT40 分80 XP
コース
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
レッスンの役割
CONTENT
順序
8 / 12

レッスン7 — apply_funding — 符号規約 + zero-sum proptest

問い

size: PositionSize(i64)(正=long)と rate: FundingRate(i64)(正=longs が払う)がある。素朴に size × rate を計算すると long × 正 rate で値は正になる。だが long の settlement delta は であるべき(longs が払う側)。この符号反転を最もきれいに encode するには?

原理(最小モデル)

  • 単項マイナス 1 つが符号規約全体を担う。 -delta_unscaled で「市場中心(longs が払う)」から「アカウント中心(Notional 正=受取)」へ flip。反転点が 2 箇所あればバグ表面積が 2 倍。1 箇所に集約が契約。
  • 保存則を proptest で pin。 balanced book の settlement 合計は saturation を踏まない範囲で「ちょうど」ゼロ(正の d-x/d = -(x/d) が整数除算でも成立)。funding は再配分するだけ、生成も破壊もしない。
  • flat position はフィルタ、エラーにしない。 size==0 を黙ってドロップ。Result<_, FlatPositionError> は呼び出し側に「異常でない条件」を扱わせる。flat は想定内の状態。
  • 最も制約の弱い引数型を受け取る。 positions: &[Position](スライス借用)なら呼び出し側が所有権を保持し tick をまたいで再利用できる。Vec 要求は毎回 clone 強制。
  • proptest のレンジは property が 厳密 に成立するよう選ぶ。 size in 1..1M なら i128 積が saturating_mul の閾値を踏まない。広げると「合計==0」を「abs < epsilon」に弱める羽目に。

具体例

size × rate の生の積がどの符号でも、先頭の単項 - 1 つで 4 ケースすべてが Notional 規約にアラインする:

【 正 rate:longs が支払う 】
  Long  (+size) × (+rate) ─►(+積)─►[ - ]─► Notional(負) ─► 支払 ⭕
  Short (-size) × (+rate) ─►(-積)─►[ - ]─► Notional(正) ─► 受取 ⭕
【 負 rate:shorts が支払う 】
  Long  (+size) × (-rate) ─►(-積)─►[ - ]─► Notional(正) ─► 受取 ⭕
  Short (-size) × (-rate) ─►(+積)─►[ - ]─► Notional(負) ─► 支払 ⭕

「1 文字に 1 つの設計判断を込める」とはこのこと。

失敗例(誤解)

- を付けず『市場 delta』として計算し、ストレージ層で反転すればいい」は誤り — 符号反転ポイントを 2 つ持つとバグの可能性が 2 倍。数学レイヤーで一度だけアカウント中心を encode すれば、下流(bridge/balance/telemetry)はすべて統一規約で Notional を読める。変換ポイントを 1 つに絞れば、テストすべき surface area が半分になる。


ここまでで「単項マイナスが符号規約を担う」は着地した。ここから pipeline の最終段(rate → アカウントごとの settlement)を組み、saturate helper の 2 番目の caller も足す。これでセクション2 が閉じる。コードは完全形。

🛑 予測。 この関数が positions: Vec<Position> でなく positions: &[Position] を取る理由は?(答え: 呼び出し側が position リストを所有し tick をまたいで再利用するから。所有権を奪うと毎回 clone が要る。スライス借用はコストゼロ。関数が使える型のうち最も制約の弱いものを受け取る — iteration だけで足りるなら Vec でなく slice。)

ステップで組み立てる

Step 1: apply_funding を追加

compute_rate の後、saturate_i128_to_i64 の前に:

/// Apply `rate` to each position, producing one [`Settlement`] per non-flat
/// position. Flat positions (`size == 0`) are dropped — there's no settlement
/// to record. Order of input positions is preserved in the output.
///
/// Sign convention: with positive `rate`, longs (positive size) pay; shorts
/// (negative size) receive. The product `size * mark * rate / RATE_SCALE`
/// is the quote-currency delta; long pays → delta is negative for longs.
#[must_use]
pub fn apply_funding(
    positions: &[Position],
    mark: MarkPrice,
    rate: FundingRate,
) -> Vec<Settlement> {
    if rate.0 == 0 {
        return Vec::new();
    }

    let mut out = Vec::with_capacity(positions.len());
    for pos in positions {
        if pos.size.0 == 0 {
            continue;
        }
        // notional = size * mark, in i128 to absorb the product's full range.
        let notional = i128::from(pos.size.0).saturating_mul(i128::from(mark.0));
        // delta_unscaled = notional * rate; still i128.
        let delta_unscaled = notional.saturating_mul(i128::from(rate.0));
        // Sign convention: longs PAY when rate > 0. The product above is
        // positive (long size * positive rate) — we flip its sign so the
        // resulting delta is negative for longs and positive for shorts.
        let delta_scaled = -delta_unscaled / i128::from(RATE_SCALE);
        out.push(Settlement {
            account: pos.account,
            delta: Notional(saturate_i128_to_i64(delta_scaled)),
        });
    }
    out
}

動く部分: rate==0 fast-path(allocation なし)/ with_capacity(再アロケート防止)/ size==0continue(flat フィルタ → 入出力の長さが違う)/ size*mark を i128+saturating_mulcompute_premium と同じレシピ)/ × rate も i128 / -delta_unscaled / RATE_SCALE(先頭 - が符号規約、除算が ppb スケールを打ち消す)。

Step 2: 4 unit test を追加

rate テストの後(proptest ブロックの前)に:

    #[test]
    fn apply_funding_skips_flat_positions() {
        let positions = vec![pos(1, 0), pos(2, 100), pos(3, 0)];
        let settlements = apply_funding(&positions, MarkPrice(100), FundingRate(1_000_000));
        assert_eq!(settlements.len(), 1);
        assert_eq!(settlements[0].account, AccountId(2));
    }

    #[test]
    fn apply_funding_longs_pay_shorts_when_rate_positive() {
        // size 100 (long), mark 100, rate 0.001 (1_000_000 ppb)
        // delta = -(100 * 100 * 1_000_000 / 1_000_000_000) = -10
        let positions = vec![pos(1, 100), pos(2, -50)];
        let s = apply_funding(&positions, MarkPrice(100), FundingRate(1_000_000));
        assert_eq!(s[0].account, AccountId(1));
        assert_eq!(s[0].delta, Notional(-10), "long pays");
        assert_eq!(s[1].account, AccountId(2));
        assert_eq!(s[1].delta, Notional(5), "short receives, half size");
    }

    #[test]
    fn apply_funding_shorts_pay_longs_when_rate_negative() {
        let positions = vec![pos(1, 100), pos(2, -50)];
        let s = apply_funding(&positions, MarkPrice(100), FundingRate(-1_000_000));
        assert_eq!(s[0].delta, Notional(10), "long receives");
        assert_eq!(s[1].delta, Notional(-5), "short pays");
    }

    #[test]
    fn apply_funding_returns_empty_on_zero_rate() {
        let positions = vec![pos(1, 100), pos(2, -50)];
        let s = apply_funding(&positions, MarkPrice(100), FundingRate(0));
        assert!(s.is_empty());
    }

4 つ: flat フィルタ(3 入力→1 出力)/ longs-pay-shorts(非対称 magnitude で delta が |size| でスケールすることも証明)/ rate 反転で対称 / zero-rate fast-path。pos helper はレッスン5 で追加済み。

Step 3: Balanced-book zero-sum proptest を追加

既存の proptest! { ... } ブロックに 2 つ目を追加:

        /// Sum of all settlement deltas is zero (or exactly the negation of
        /// itself with saturation tolerance) when the population is balanced.
        /// Equivalently: funding redistributes between longs and shorts —
        /// it doesn't create or destroy quote currency.
        ///
        /// We test the property by constructing equal-and-opposite long/short
        /// pairs and asserting their settlements sum to zero exactly.
        #[test]
        fn balanced_book_settlements_sum_to_zero(
            size in 1i64..1_000_000,
            mark in 1u64..1_000_000,
            rate in -10_000_000i64..10_000_000,
        ) {
            let positions = vec![
                pos(1, size),
                pos(2, -size),
            ];
            let s = apply_funding(&positions, MarkPrice(mark), FundingRate(rate));
            if rate == 0 {
                prop_assert!(s.is_empty());
            } else {
                prop_assert_eq!(s.len(), 2);
                prop_assert_eq!(s[0].delta.0 + s[1].delta.0, 0);
            }
        }

zero-sum は funding の根本的保存則。(+P, -P) の対称ペアでは整数除算の切り捨てがあっても (-P)/d == -(P/d)d>0)が保たれ、端数も対称に相殺して tolerance なしで和が厳密に 0。size in 1..1M に絞るのは i128 積が saturate しない領域に留めるため(saturate すると long 側と short 側がちょうど負にならず property が壊れる)。

Step 4: lib.rs を更新

pub use compute::{apply_funding, compute_premium, compute_rate};

セクション2 の 3 pure 関数がすべて re-export された。

Step 5: テスト実行

cargo test -p openhl-funding が 15 pass(手書き 13 + proptest 2)。rustdoc warning は FundingClock 1 つだけ(レッスン8 で解決)。セクション2 が閉じる。

答え合わせ

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

compute.rs は cd94137完全一致(3 pure 関数 + helper + 全テスト + 2 proptest)。lib.rs は pub mod clock; とその re-export だけ欠ける(レッスン8)。

合格基準

cargo test -p openhl-funding が 15 pass。よくあるミス: delta_unscaled の前の - 忘れで long も short も同符号 delta(相殺しない)/ pos.size の signed を見落とし符号追跡が脆い / proptest 失敗時は符号反転を確認(long と short が反対符号・等規模の delta)。

まとめ(3行)

  • apply_funding は rate を各 non-flat position に適用し Vec<Settlement> を返す。先頭の単項マイナス -delta_unscaled 1 つが市場中心→アカウント中心の符号規約を 4 ケースすべてで担う。
  • flat position はフィルタ(エラーにしない、想定内の状態)、入力は &[Position](最も制約の弱い型)・出力は owned Vec。積は i128 + saturating_mul
  • balanced-book zero-sum proptest が funding の保存則を pin(再配分するだけ、生成も破壊もしない)。(+P,-P) 対称ペアで端数も相殺し tolerance なしで和が 0。これでセクション2(15 tests)完了。

次のレッスン(レッスン8)

clock.rs を作成 — FundingClock 構造体と tick()。「十分な時間が経過したか?」の guard の背後で compute_premium/compute_rate/apply_funding を組み合わせる discrete event loop。不変条件(interval ごと最多 1 回 / no-catch-up)はレッスン9/10 が独立して受け持つ。