Fix 1 (HRP, IMPORTANT): both the main bisection path and the except-branch fallback in hrp_weights now guard the weight vector after computation — if any weight is non-finite, negative, or the sum is ≤ 0 (triggered by a zero-variance asset causing 1/0→inf in _ivp), the result is replaced with a uniform distribution (1/n each), which is always a valid allocation. Prevents NaN weights from silently propagating to capital allocation. Fix 2 (correlation, MINOR): replace exact `std() == 0` zero-vol guards in full_correlation and left_tail_correlation with `std() < 1e-12`, so arrays with near-constant float-noise (std ~1e-17) return NaN instead of a garbage corrcoef value. Fix 3 (gate, MINOR): split the formerly combined `not isfinite(mg) or mg < min_marginal` branch into two: a non-finite marginal now reports "marginal Sharpe unestimable — fail-safe reject" (distinct from the "< threshold" message), making log/alert triage unambiguous. Admit/reject outcome is unchanged. Fix 4 (correlation, MINOR): _aligned now returns two empty arrays immediately when either input has length 0, guarding against the a[-0:] == a[:] footgun that would otherwise return the full array. Downstream len < 2 guards then produce NaN as designed. New tests: test_hrp_zero_vol_asset_falls_back_to_valid, test_near_constant_returns_nan, test_empty_input_is_nan_not_raise, test_unestimable_marginal_reason_is_distinct. All 203 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
48 lines
2.1 KiB
Python
48 lines
2.1 KiB
Python
"""Diversification gate: admit a forward-survivor only if left-tail corr is low AND it adds Sharpe.
|
|
Fail-SAFE: if the tail can't be estimated, do NOT admit."""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from fxhnt.domain.diversification.gate import diversification_gate
|
|
|
|
|
|
def test_admits_uncorrelated_additive_sleeve() -> None:
|
|
rng = np.random.default_rng(0)
|
|
book = rng.normal(0.0005, 0.01, 1000)
|
|
sleeve = rng.normal(0.0005, 0.01, 1000)
|
|
v = diversification_gate(sleeve, book, max_tail_corr=0.5, min_marginal=0.0, tail_frac=0.1)
|
|
assert v.admit is True
|
|
|
|
|
|
def test_rejects_tail_correlated_sleeve() -> None:
|
|
rng = np.random.default_rng(1)
|
|
book = rng.normal(0.0005, 0.01, 1000)
|
|
sleeve = book.copy() # perfectly correlated incl. tail
|
|
v = diversification_gate(sleeve, book, max_tail_corr=0.5, min_marginal=0.0, tail_frac=0.1)
|
|
assert v.admit is False and "tail" in v.reason.lower()
|
|
|
|
|
|
def test_failsafe_when_tail_unestimable() -> None:
|
|
book = np.array([0.01, 0.02, -0.01]) # too few tail days
|
|
sleeve = np.array([0.0, 0.0, 0.0])
|
|
v = diversification_gate(sleeve, book, max_tail_corr=0.5, min_marginal=0.0, tail_frac=0.1)
|
|
assert v.admit is False and "unestimable" in v.reason.lower()
|
|
|
|
|
|
def test_unestimable_marginal_reason_is_distinct() -> None:
|
|
"""A non-finite marginal Sharpe must report 'unestimable', not the '< threshold' message.
|
|
|
|
Scenario: sleeve = -4 * book so the blended series (1-0.2)*book + 0.2*sleeve = 0 everywhere
|
|
→ blended std == 0 → _sharpe returns NaN → marginal_sharpe returns NaN.
|
|
The left-tail correlation is -1 (finite, passes < 0.5 gate), so we reach the marginal branch.
|
|
"""
|
|
rng = np.random.default_rng(9)
|
|
n = 1000
|
|
book = rng.normal(0.0005, 0.01, n)
|
|
sleeve = -4.0 * book # blended = 0.8*book + 0.2*(-4*book) = 0
|
|
v = diversification_gate(sleeve, book, max_tail_corr=0.5, min_marginal=0.05, tail_frac=0.1)
|
|
assert v.admit is False
|
|
assert "unestimable" in v.reason.lower()
|
|
assert "marginal" in v.reason.lower()
|