Files
fxhnt/tests/unit/test_dd_controller_v1a.py
jgrusewski 994bb59641 test: parallelize (pytest-xdist -n auto) + optimize the slow tests
- pytest-xdist added to dev deps; addopts='-n auto' runs the ~1940-test suite across all
  cores (~6-9min -> ~1.5min); registered a 'slow' marker for a serial '-m "not slow"' loop.
- equity_backtest_runner liquidity fixture: trimmed the 3000-bar (8yr) LIVELONG/ILLIQUID
  histories to 800 bars (still >> min_history=300, multi-year for peak-yearly ranking) —
  the peak-yearly aggregation over 8yr was the cost: 50s/30s/19s tests -> ~5.8s each,
  assertions unchanged.
- paper_sim perf guards: made them parallel-robust (the wall-clock <1.6s bound flaked under
  -n auto CPU contention). full-history guard relaxed to <10s (it targets an O(D²) blowup =
  orders of magnitude, not a constant factor); cheap-reapply made RELATIVE (< full/10, ratio
  is contention-invariant). Both marked slow.

Full suite 1940 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:51:52 +02:00

194 lines
8.0 KiB
Python

"""v1a drawdown controller: kelly_for_dd_budget, PageHinkleyDecay.health, and the edge_health overlay.
These cover the three pure pieces plus the edge_health wiring into both overlays (live==replay), and
guard that the default controller="killswitch" path is unchanged.
"""
import pytest
from fxhnt.application.paper_book import (
IncrementalRiskOverlay,
_DD_BUDGET,
kelly_for_dd_budget,
paper_risk_overlay,
)
from fxhnt.domain.edge_decay import PageHinkleyDecay
# --- A. kelly_for_dd_budget ----------------------------------------------------------------------
def test_kelly_for_dd_budget_default_budget():
# -0.25 -> (25 - 21)/12 = 0.3333...
assert kelly_for_dd_budget(-0.25) == pytest.approx((25.0 - 21.0) / 12.0)
assert kelly_for_dd_budget(_DD_BUDGET) == pytest.approx(1.0 / 3.0)
def test_kelly_for_dd_budget_minus_33_is_one():
# -0.33 -> (33 - 21)/12 = 1.0
assert kelly_for_dd_budget(-0.33) == pytest.approx(1.0)
def test_kelly_for_dd_budget_clamps_low():
# Tiny budget -> well below kmin -> clamps to kmin.
assert kelly_for_dd_budget(-0.05) == 0.1
assert kelly_for_dd_budget(0.0) == 0.1
def test_kelly_for_dd_budget_clamps_high():
# Large budget -> above kmax -> clamps to kmax.
assert kelly_for_dd_budget(-0.90) == 1.5
def test_kelly_for_dd_budget_sign_insensitive():
assert kelly_for_dd_budget(-0.25) == kelly_for_dd_budget(0.25)
# --- B. PageHinkleyDecay.health ------------------------------------------------------------------
def test_health_fresh_is_one():
ph = PageHinkleyDecay()
assert ph.health() == 1.0 # m_max == m_t == 0
def test_health_deviation_equal_lam_is_theta_min():
ph = PageHinkleyDecay(lam=0.20)
ph.m_max = 0.5
ph.m_t = 0.3 # dev = 0.2 == lam -> 1 - 1 = 0
assert ph.health(theta_min=0.0) == pytest.approx(0.0)
assert ph.health(theta_min=0.1) == pytest.approx(0.1) # clamps up to theta_min
def test_health_clamps_high_when_m_t_above_m_max():
# m_t > m_max can't happen via update (m_max tracks the peak), but health must still clamp to 1.0.
ph = PageHinkleyDecay(lam=0.20)
ph.m_max = 0.1
ph.m_t = 0.5
assert ph.health() == 1.0
def test_health_does_not_mutate():
ph = PageHinkleyDecay()
for _ in range(20):
ph.update(0.001)
before = ph.to_dict()
_ = ph.health()
assert ph.to_dict() == before
def test_health_drops_on_decaying_series_holds_on_positive():
# PH detects a downward DRIFT/shift in the mean (anti-whipsaw: a constant level does NOT trip it).
pos = PageHinkleyDecay(lam=0.05)
neg = PageHinkleyDecay(lam=0.05)
for _ in range(60):
pos.update(0.002) # steadily positive -> stays healthy
for _ in range(40):
neg.update(0.005) # establish a positive regime ...
for _ in range(40):
neg.update(-0.01) # ... then a sustained downward shift = edge decay
assert pos.health() == pytest.approx(1.0) # healthy
assert neg.health() < 0.5 # decayed -> de-sized hard
def test_health_partial_decay_is_between():
ph = PageHinkleyDecay(lam=0.20)
ph.m_max = 0.5
ph.m_t = 0.4 # dev 0.1 -> 1 - 0.5 = 0.5
assert ph.health() == pytest.approx(0.5)
# --- C. edge_health overlay correctness ----------------------------------------------------------
def _series(vals, start=1):
return {start + i: v for i, v in enumerate(vals)}
def test_edge_health_desizes_decaying_sleeve_only():
healthy = _series([0.01, 0.012, 0.008, 0.011] * 20) # steady positive
# A downward SHIFT (positive regime then negative) = edge decay PH detects (not a constant level).
decaying = _series([0.01] * 40 + [-0.02] * 40)
R = {"good": healthy, "bad": decaying}
w, _lev, killed = paper_risk_overlay(R, ["good", "bad"], controller="edge_health")
assert killed is False
# Compare against the un-adjusted ISV weights: bad is cut hard by theta~0, good is ~untouched (theta~1).
from fxhnt.application.paper_book import isv_adaptive_weights
isv = isv_adaptive_weights(R, ["good", "bad"])
assert w["bad"] < 0.01 * isv["bad"] # decaying sleeve de-sized to ~zero
assert w["good"] == pytest.approx(isv["good"], rel=0.05) # healthy sleeve ~untouched (theta~1)
def test_edge_health_total_exposure_drops_when_a_sleeve_decays():
healthy = _series([0.01, 0.012, 0.008, 0.011] * 20)
decaying = _series([0.01] * 40 + [-0.02] * 40) # downward shift = edge decay
R_calm = {"good": healthy, "good2": _series([0.009, 0.011, 0.01, 0.012] * 20)}
R_decay = {"good": healthy, "bad": decaying}
w_calm, _, _ = paper_risk_overlay(R_calm, ["good", "good2"], controller="edge_health")
w_decay, _, _ = paper_risk_overlay(R_decay, ["good", "bad"], controller="edge_health")
# Healthy book keeps near-full exposure (theta~1 on noise); the decaying book is cut materially below it.
assert sum(w_calm.values()) > 0.98
assert sum(w_decay.values()) < sum(w_calm.values()) - 0.1
def test_edge_health_never_kills_even_on_deep_drawdown():
losing = _series([-0.03] * 60)
_, _, killed = paper_risk_overlay({"a": losing}, ["a"], controller="edge_health")
assert killed is False
def test_edge_health_uses_dd_budget_kelly():
# Strong-Sharpe, unsaturated leverage so the Kelly multiplier is visible: edge_health leverage uses
# kelly_for_dd_budget(_DD_BUDGET) ~ 0.333, not the default 1.0.
from fxhnt.application.paper_book import (
isv_adaptive_weights, paper_book_returns, adaptive_target_vol, vol_target_leverage)
R = {"a": {i + 1: (0.05 if i % 2 else 0.045) for i in range(80)}} # healthy -> theta~1
w, lev, _ = paper_risk_overlay(R, ["a"], controller="edge_health")
bk = kelly_for_dd_budget(_DD_BUDGET)
bret = paper_book_returns(R, kelly_fraction=bk)
tv = adaptive_target_vol(list(bret.values()))
expected = vol_target_leverage(w, R, target_vol=tv, kelly_fraction=bk)
assert lev == pytest.approx(expected)
# And it differs from the full-Kelly (killswitch) leverage when unsaturated.
isv = isv_adaptive_weights(R, ["a"])
bret_full = paper_book_returns(R)
tv_full = adaptive_target_vol(list(bret_full.values()))
full_lev = vol_target_leverage(isv, R, target_vol=tv_full, kelly_fraction=1.0)
assert lev < full_lev - 1e-9
# --- D. live==replay golden for edge_health ------------------------------------------------------
def _golden_fixture():
import random
rng = random.Random(20260623)
spec = [
("a", 1, 200, 0.0008, 0.004),
("b", 11, 190, 0.0, 0.020),
("c", 6, 180, -0.010, 0.020), # negative drift -> drives theta down -> exercises health path
]
full: dict[str, dict[int, float]] = {}
for name, start, n, mu, sd in spec:
full[name] = {start + i: rng.gauss(mu, sd) for i in range(n)}
all_days = sorted({d for r in full.values() for d in r})
return full, all_days
@pytest.mark.slow
def test_incremental_edge_health_matches_per_date_exact():
full, all_days = _golden_fixture()
inc = IncrementalRiskOverlay(controller="edge_health")
acc: dict[str, dict[int, float]] = {}
saw_desized = False
for cutoff in [d + 1 for d in all_days]:
newest = cutoff - 1
for s, r in full.items():
if newest in r:
acc.setdefault(s, {})[newest] = r[newest]
active = [s for s, r in acc.items() if r]
w_ref, lev_ref, killed_ref = paper_risk_overlay(acc, active, controller="edge_health")
w_inc, lev_inc, killed_inc = inc.overlay(acc, active)
assert w_inc == w_ref, f"day {cutoff}: weights {w_inc} != {w_ref}"
assert lev_inc == lev_ref, f"day {cutoff}: leverage {lev_inc!r} != {lev_ref!r}"
assert killed_inc is False and killed_ref is False
# detect that the health path actually de-sized something (theta<1 -> sum<1)
if active and sum(w_ref.values()) < 1.0 - 1e-6:
saw_desized = True
assert saw_desized, "fixture never de-sized a sleeve — edge_health path not exercised"