Files
fxhnt/tests/unit/test_correlation.py
jgrusewski 5f5c2a693f fix(factory): fail-safe diversification core — HRP uniform fallback, corr epsilon + empty guard, gate reason
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>
2026-06-14 20:06:52 +02:00

50 lines
2.2 KiB
Python

"""Full-sample and LEFT-TAIL correlation. Left-tail = correlation on the book's worst days (crisis-relevant)."""
from __future__ import annotations
import numpy as np
from fxhnt.domain.diversification.correlation import full_correlation, left_tail_correlation
def test_full_correlation_aligned() -> None:
a = np.array([1.0, -1.0, 2.0, -2.0, 0.5])
assert abs(full_correlation(a, a) - 1.0) < 1e-9
assert abs(full_correlation(a, -a) + 1.0) < 1e-9
def test_left_tail_picks_book_worst_days() -> None:
# book: last two days are the worst; sleeve co-moves ONLY on those days -> high left-tail corr,
# near-zero full-sample corr.
rng = np.random.default_rng(0)
n = 200
book = rng.normal(0, 0.01, n)
book[-2:] = [-0.05, -0.06] # crisis days
sleeve = rng.normal(0, 0.01, n)
sleeve[-2:] = [-0.05, -0.07] # sleeve crashes WITH the book
lt = left_tail_correlation(sleeve, book, tail_frac=0.02) # worst ~4 days
fs = full_correlation(sleeve, book)
assert lt > 0.5 # co-moves in the tail
assert lt > fs # tail corr exceeds full-sample (the crisis-hiding case)
def test_near_constant_returns_nan() -> None:
"""A series with std ~1e-15 (float noise around a constant) must return NaN, not a garbage value."""
rng = np.random.default_rng(7)
# near-constant: tiny float noise well below the 1e-12 epsilon
near_const = np.ones(200) + rng.normal(0, 1e-15, 200)
normal = rng.normal(0, 0.01, 200)
result = full_correlation(near_const, normal)
assert np.isnan(result), f"expected NaN for near-constant input, got {result}"
def test_empty_input_is_nan_not_raise() -> None:
"""Empty input must return NaN without raising — _aligned's a[-0:] footgun is guarded."""
result = full_correlation(np.array([]), np.array([1.0, 2.0]))
assert np.isnan(result), f"expected NaN for empty input, got {result}"
def test_left_tail_unestimable_returns_nan() -> None:
book = np.array([0.01, 0.02]) # too few days for a tail
sleeve = np.array([0.0, 0.0])
assert np.isnan(left_tail_correlation(sleeve, book, tail_frac=0.1))