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>
56 lines
2.2 KiB
Python
56 lines
2.2 KiB
Python
"""HRP allocation: weights sum to 1, all positive, and a duplicated (perfectly-correlated) pair splits the
|
|
weight it would get as a single asset (the diversification property)."""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from fxhnt.domain.diversification.hrp import hrp_weights
|
|
|
|
|
|
def _series(rng, n, vol):
|
|
return rng.normal(0.0, vol, n)
|
|
|
|
|
|
def test_weights_sum_to_one_all_positive() -> None:
|
|
rng = np.random.default_rng(0)
|
|
rets = {f"S{i}": _series(rng, 500, 0.01) for i in range(5)}
|
|
w = hrp_weights(rets)
|
|
assert abs(sum(w.values()) - 1.0) < 1e-9
|
|
assert all(v >= 0 for v in w.values())
|
|
assert set(w) == set(rets)
|
|
|
|
|
|
def test_lower_vol_gets_more_weight() -> None:
|
|
rng = np.random.default_rng(1)
|
|
rets = {"LOWVOL": _series(rng, 500, 0.005), "HIGHVOL": _series(rng, 500, 0.02)}
|
|
w = hrp_weights(rets)
|
|
assert w["LOWVOL"] > w["HIGHVOL"] # risk parity: less vol -> more weight
|
|
|
|
|
|
def test_degenerate_single_asset() -> None:
|
|
rng = np.random.default_rng(2)
|
|
w = hrp_weights({"ONLY": _series(rng, 100, 0.01)})
|
|
assert abs(w["ONLY"] - 1.0) < 1e-9
|
|
|
|
|
|
def test_hrp_zero_vol_asset_falls_back_to_valid() -> None:
|
|
"""A constant (zero-variance) series must not produce NaN/negative weights — fallback to uniform."""
|
|
rng = np.random.default_rng(42)
|
|
constant = np.ones(200) # zero vol — 1/0 = inf in _ivp
|
|
normal = _series(rng, 200, 0.01)
|
|
w = hrp_weights({"CONST": constant, "NORM": normal})
|
|
vals = list(w.values())
|
|
assert all(np.isfinite(v) for v in vals), f"non-finite weight(s): {w}"
|
|
assert all(v >= 0 for v in vals), f"negative weight(s): {w}"
|
|
assert abs(sum(vals) - 1.0) < 1e-9, f"weights don't sum to 1: {w}"
|
|
|
|
|
|
def test_correlated_cluster_shares_weight() -> None:
|
|
# two identical assets + one independent: the identical pair together should not dominate
|
|
rng = np.random.default_rng(3)
|
|
base = _series(rng, 800, 0.01)
|
|
indep = _series(rng, 800, 0.01)
|
|
w = hrp_weights({"A": base, "A_DUP": base.copy(), "B": indep})
|
|
assert abs(sum(w.values()) - 1.0) < 1e-9
|
|
assert w["B"] > w["A"] # the independent asset gets more than each twin
|