レッスン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==0 を continue(flat フィルタ → 入出力の長さが違う)/ size*mark を i128+saturating_mul(compute_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_unscaled1 つが市場中心→アカウント中心の符号規約を 4 ケースすべてで担う。- flat position はフィルタ(エラーにしない、想定内の状態)、入力は
&[Position](最も制約の弱い型)・出力は ownedVec。積は 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 が独立して受け持つ。