domain/features/ofi: faithful port of Rust ml-features ofi_calculator.rs stateless functions — ofi_per_level (Cont 2010 per-level OFI), log_gofi (signed-log), book_deltas_per_level, slope_and_convexity (cumulative-depth shape) — + stateful OfiFeatures calculator (24 features). 8 TDD tests vs the Rust oracle (balanced-unchanged=0, upward-move>0, log-gofi sign/value, deltas, linear-cumulative slope=10/convex=0, <3-levels guard, cold-start). Prioritized by the conservation principle (order flow = where capital moves). 39/39 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.3 KiB
Python
80 lines
3.3 KiB
Python
"""OFI port — TDD against the Rust ml-features `ofi_calculator.rs` test oracle (Blocks F/G/H/CC)."""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
from fxhnt.domain.features.ofi import (
|
||
OfiFeatures,
|
||
book_deltas_per_level,
|
||
log_gofi,
|
||
ofi_per_level,
|
||
slope_and_convexity,
|
||
)
|
||
from fxhnt.domain.marketdata import BidAskLevel, Mbp10Snapshot
|
||
|
||
_B, _A = 150_000_000_000, 150_010_000_000 # 150.00 / 150.01
|
||
|
||
|
||
def _snap(*levels: BidAskLevel) -> Mbp10Snapshot:
|
||
return Mbp10Snapshot(symbol="ES", ts=0, levels=tuple(levels))
|
||
|
||
|
||
def _lvl(bid_px: int, bid_sz: int, ask_px: int, ask_sz: int) -> BidAskLevel:
|
||
return BidAskLevel(bid_px=bid_px, bid_sz=bid_sz, ask_px=ask_px, ask_sz=ask_sz)
|
||
|
||
|
||
def test_ofi_per_level_balanced_unchanged_is_zero() -> None:
|
||
snap = _snap(_lvl(_B, 100, _A, 100), _lvl(_B - 10**9, 100, _A + 10**9, 100))
|
||
assert ofi_per_level(snap, snap) == [0.0, 0.0, 0.0, 0.0, 0.0] # P_b≥P_b ∧ P_a≤P_a, balanced sizes
|
||
|
||
|
||
def test_ofi_per_level_upward_move_is_positive() -> None:
|
||
prev = _snap(_lvl(_B, 100, _A, 100))
|
||
curr = _snap(_lvl(_B + 5_000_000, 100, _A + 5_000_000, 100)) # bid rises (+V_b), ask rises (−V_a^prev)
|
||
ofi = ofi_per_level(curr, prev)
|
||
assert ofi[0] == 200.0 and ofi[0] > 0 # 100 − (−100)
|
||
|
||
|
||
def test_ofi_per_level_truncates_to_common_levels() -> None:
|
||
curr = _snap(_lvl(_B, 100, _A, 100)) # 1 level
|
||
prev = _snap(_lvl(_B, 100, _A, 100), _lvl(_B - 10**9, 50, _A + 10**9, 50)) # 2 levels
|
||
assert ofi_per_level(curr, prev) == [0.0] * 5
|
||
|
||
|
||
def test_log_gofi_sign_and_value() -> None:
|
||
out = log_gofi([10.0, -10.0, 1000.0, -1000.0, 0.0])
|
||
assert out[0] == math.log1p(10.0) and out[1] == -math.log1p(10.0)
|
||
assert out[2] == math.log1p(1000.0) and out[3] == -math.log1p(1000.0)
|
||
assert out[4] == 0.0 # 0 → 0 (ln1p(0)=0)
|
||
|
||
|
||
def test_book_deltas_size_changes() -> None:
|
||
prev = _snap(_lvl(_B, 100, _A, 100))
|
||
curr = _snap(_lvl(_B, 130, _A, 80))
|
||
bid_d, ask_d = book_deltas_per_level(curr, prev)
|
||
assert bid_d[0] == 30.0 and ask_d[0] == -20.0
|
||
|
||
|
||
def test_slope_convexity_linear_cumulative_depth() -> None:
|
||
# uniform per-level size 10 → cumulative [10,20,30,40,50]: slope 10, convexity 0 (linear)
|
||
levels = [_lvl(_B - i * 10**9, 10, _A + i * 10**9, 10) for i in range(5)]
|
||
sc = slope_and_convexity(_snap(*levels))
|
||
assert abs(sc[0] - 10.0) < 1e-9 and abs(sc[1]) < 1e-9 # bid slope 10, convexity 0
|
||
assert abs(sc[2] - 10.0) < 1e-9 and abs(sc[3]) < 1e-9
|
||
|
||
|
||
def test_slope_convexity_needs_three_levels() -> None:
|
||
assert slope_and_convexity(_snap(_lvl(_B, 10, _A, 10), _lvl(_B, 10, _A, 10))) == [0.0, 0.0, 0.0, 0.0]
|
||
|
||
|
||
def test_ofi_features_cold_start_then_flows() -> None:
|
||
f = OfiFeatures()
|
||
levels = [_lvl(_B - i * 10**9, 10, _A + i * 10**9, 10) for i in range(5)]
|
||
r0 = f.update(_snap(*levels))
|
||
assert r0["ofi_l1"] == 0.0 and r0["bid_delta_l1"] == 0.0 # cold start: no flow yet
|
||
assert abs(r0["cum_bid_slope"] - 10.0) < 1e-9 # but slope/convexity are current-only
|
||
# second snapshot: bid grows at L1 -> positive delta
|
||
levels2 = [_lvl(_B - i * 10**9, 10 + (20 if i == 0 else 0), _A + i * 10**9, 10) for i in range(5)]
|
||
r1 = f.update(_snap(*levels2))
|
||
assert r1["bid_delta_l1"] == 20.0
|