Files
fxhnt/tests/unit/test_cusum.py
jgrusewski c0844eb7b8 feat: CUSUM structural-break detector ported (regime #6) — the edge-decay signal
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>
2026-06-13 17:56:19 +02:00

44 lines
1.5 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.
"""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