domain/regime/cusum.CusumDetector: faithful port of Rust ml-regime/cusum.rs — two-sided CUSUM (Page 1954), standardize then accumulate drift-allowed deviations, flag a break when S+ or S- crosses h (k,h in sigma units). O(1) online. This is the changepoint/edge-decay detector forward-validation needs: it catches a structural shift in a deployed specialist's returns with minimal delay (no fixed-window lag). 4 TDD tests (stable=no-break, upward 3rd-update break at 7.5, downward negative-magnitude, reset). 52/52. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
44 lines
1.5 KiB
Python
44 lines
1.5 KiB
Python
"""CUSUM detector — TDD against the Rust ml-regime/cusum.rs algorithm (two-sided, σ-unit drift/threshold)."""
|
||
from __future__ import annotations
|
||
|
||
from fxhnt.domain.regime import CusumDetector
|
||
|
||
|
||
def _detector() -> CusumDetector:
|
||
return CusumDetector(target_mean=0.0, target_std=1.0, drift_allowance=0.5, detection_threshold=5.0)
|
||
|
||
|
||
def test_stable_series_no_break() -> None:
|
||
d = _detector()
|
||
assert all(d.update(0.0) is None for _ in range(100)) # at target, drift keeps sums at 0
|
||
assert d.positive_sum == 0.0 and d.negative_sum == 0.0
|
||
|
||
|
||
def test_upward_shift_detected() -> None:
|
||
d = _detector()
|
||
# 3σ jumps: S⁺ grows by 2.5 each step (3 − 0.5) → crosses 5 on the 3rd update (2.5, 5.0, 7.5)
|
||
assert d.update(3.0) is None # S⁺ = 2.5
|
||
assert d.update(3.0) is None # S⁺ = 5.0 (not > 5)
|
||
brk = d.update(3.0) # S⁺ = 7.5 > 5
|
||
assert brk is not None and brk.direction == "positive"
|
||
assert abs(brk.magnitude - 7.5) < 1e-9 and brk.magnitude > 0
|
||
assert brk.observations_since_reset == 3
|
||
|
||
|
||
def test_downward_shift_has_negative_magnitude() -> None:
|
||
d = _detector()
|
||
brk = None
|
||
for _ in range(3):
|
||
brk = d.update(-3.0)
|
||
assert brk is not None and brk.direction == "negative"
|
||
assert abs(brk.magnitude + 7.5) < 1e-9 and brk.magnitude < 0
|
||
|
||
|
||
def test_reset_clears_state() -> None:
|
||
d = _detector()
|
||
for _ in range(3):
|
||
d.update(3.0)
|
||
d.reset()
|
||
assert d.positive_sum == 0.0 and d.negative_sum == 0.0
|
||
assert d.update(0.0) is None
|