Files
fxhnt/tests/unit/test_ofi.py
jgrusewski f0d5bd377b feat: OFI features TDD-ported (Blocks F/G/H/CC) — the follow-the-money signal
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>
2026-06-13 02:06:30 +02:00

80 lines
3.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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