ForwardValidationService now runs a CUSUM over each FORWARD specialist's in-regime forward return sequence (StrategyRecord.forward_returns, persisted): a structural NEGATIVE break -> RETIRE with minimal delay, even before min_forward_days. This is the edge-decay/trust layer the foxhunt memory flagged as the 'missing abstraction' between Kelly (long-run sizing) and the circuit breaker (tail kill) — a slow bleed that passes mean/threshold filters is caught by the changepoint detector. _review order: CUSUM-decay -> min-days gate -> deploy(mean>0)/retire. 53/53 tests (decayed retired, holding-edge deployed, young keeps tracking). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 lines
2.4 KiB
Python
52 lines
2.4 KiB
Python
"""Forward-validation review: CUSUM edge-decay retires a decaying specialist; a holding edge deploys;
|
|
a young one keeps tracking."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from fxhnt.adapters.persistence import DuckDbAnalyticalStore, SqlStrategyStore
|
|
from fxhnt.application.forward import ForwardValidationService
|
|
from fxhnt.config import Settings
|
|
from fxhnt.domain.factory import RegimeFit, StrategyRecord, StrategyStatus
|
|
from fxhnt.domain.models import AssetClass, Market, PriceSeries, StrategySpec
|
|
|
|
_NOW = dt.datetime(2026, 6, 13, tzinfo=dt.timezone.utc)
|
|
|
|
|
|
class FakeData:
|
|
name = "fake"
|
|
|
|
def fetch(self, market: Market, start=None, end=None) -> PriceSeries: # not used by _review
|
|
import numpy as np
|
|
return PriceSeries(market=market, dates=("0",) * 400, close=np.ones(400))
|
|
|
|
|
|
def _fwd(sid: str, returns: list[float]) -> StrategyRecord:
|
|
return StrategyRecord(
|
|
strategy_id=sid, markets=["NQ"], asset_class=AssetClass.FUTURE,
|
|
spec=StrategySpec(kind="trend", params={"window": 200.0}),
|
|
regime_fits=[RegimeFit(regime="trend_up|low_vol", dsr=0.97, is_sharpe=1.3, oos_sharpe=0.9, passed=True)],
|
|
status=StrategyStatus.FORWARD, discovered_at=_NOW, updated_at=_NOW,
|
|
forward_days=len(returns), forward_sum=sum(returns), forward_sumsq=sum(r * r for r in returns),
|
|
forward_returns=returns,
|
|
)
|
|
|
|
|
|
def test_forward_review_cusum_gate(tmp_path) -> None:
|
|
settings = Settings(operational_dsn=f"sqlite:///{tmp_path / 's.db'}", analytical_path=str(tmp_path / "a.duckdb"))
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
svc = ForwardValidationService(FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
min_forward_days=15)
|
|
|
|
decayed = _fwd("DEC", [0.01] * 10 + [-0.05] * 20) # clear structural down-shift -> CUSUM negative break
|
|
good = _fwd("GOOD", [0.01] * 30) # positive, no negative break
|
|
young = _fwd("YOUNG", [0.002] * 5) # < warmup and < min_days
|
|
for rec in (decayed, good, young):
|
|
store.upsert(rec)
|
|
|
|
svc._review() # noqa: SLF001
|
|
|
|
assert store.get("DEC").status is StrategyStatus.RETIRED # edge decayed -> retired by CUSUM
|
|
assert store.get("GOOD").status is StrategyStatus.DEPLOYED # edge held -> deployed
|
|
assert store.get("YOUNG").status is StrategyStatus.FORWARD # still tracking
|