- 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>
194 lines
8.0 KiB
Python
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"
|