"""Deterministic statistical utilities (src/countryatlas/stats.py).""" from __future__ import annotations import math import pytest from countryatlas import stats def test_growth_helpers(): assert stats.yoy(110, 100) == pytest.approx(10.0) assert stats.yoy(90, -100) == pytest.approx(190.0) # relative to |prev| assert stats.yoy(1, 0) is None and stats.yoy(None, 1) is None assert stats.cagr(100, 200, 10) == pytest.approx((2 ** 0.1 - 1) * 100) assert stats.cagr(0, 5, 3) is None and stats.cagr(5, 5, 0) is None assert stats.rolling_mean([1, 2, 3, None, 5], 2) == [None, 1.5, 2.5, None, None] assert stats.trend_slope([2000, 2001, 2002, 2003], [1, 3, 5, 7]) == pytest.approx(2.0) assert stats.trend_slope([2000, 2001], [1, 2]) is None assert stats.volatility([1, 1, 1, 1]) == pytest.approx(0.0) assert stats.volatility([100, 110, 121], relative=True) == pytest.approx(0.0, abs=1e-9) def test_ranks_and_percentiles(): assert stats.rank([3, 1, None, 3, 2]) == [1, 4, None, 1, 3] assert stats.rank([3, 1, 2], descending=False) == [3, 1, 2] pct = stats.percentile_rank([10, 20, 30, None]) assert pct[:3] == [0.0, 50.0, 100.0] and pct[3] is None tie = stats.percentile_rank([1, 2, 2, 3]) assert tie[1] == tie[2] == pytest.approx(50.0) z = stats.zscore([1, 2, 3]) assert z[1] == pytest.approx(0.0) and z[2] == pytest.approx(math.sqrt(1.5)) assert stats.zscore([5, 5, 5]) == [None, None, None] rz = stats.robust_zscore([1, 2, 3, 4, 100]) assert rz[4] > 10 and abs(rz[2]) < 1e-9 assert stats.median([3, None, 1, 2]) == 2.0 assert stats.weighted_mean([10, 20], [1, 3]) == pytest.approx(17.5) assert stats.weighted_mean([10, 20], [0, None]) is None def test_records_and_breaks(): assert stats.is_record_high([1, 2, 3]) and not stats.is_record_high([3, 2, 1]) and not stats.is_record_high([3]) assert stats.is_record_low([3, 2, 1]) and not stats.is_record_low([1, 2, 3]) series = [10] * 8 + [20] * 8 b = stats.structural_break(series, min_segment=5) assert b is not None and b["index"] == 8 and b["gain"] == pytest.approx(1.0) and b["shift"] == pytest.approx(10.0) assert stats.structural_break([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) is None # flat assert stats.structural_break([1, 2, 3], min_segment=5) is None # too short assert stats.persistent_reversal([5, 4, 3, 2, 3, 4, 5]) == "up" assert stats.persistent_reversal([1, 2, 3, 4, 3, 2, 1]) == "down" assert stats.persistent_reversal([1, 2, 3, 4, 5, 6, 7]) is None def test_association(): x = [1, 2, 3, 4, 5] r, n = stats.pearson(x, [2, 4, 6, 8, 10]) assert r == pytest.approx(1.0) and n == 5 r, n = stats.pearson(x, [5, 4, 3, 2, 1]) assert r == pytest.approx(-1.0) assert stats.pearson(x, [1, 1, 1, 1, 1]) == (None, 5) rho, _ = stats.spearman(x, [1, 10, 100, 1000, 10000]) assert rho == pytest.approx(1.0) fit = stats.ols(x, [3, 5, 7, 9, 11]) assert fit["slope"] == pytest.approx(2.0) and fit["intercept"] == pytest.approx(1.0) and fit["r2"] == pytest.approx(1.0) ts = stats.theil_sen(x, [3, 5, 7, 9, 100]) # one outlier does not move the robust slope assert ts["slope"] == pytest.approx(2.0) and ts["intercept"] == pytest.approx(1.0) assert stats.ols([1, 1, 1], [1, 2, 3]) is None def test_breaks_histogram_convergence(): br = stats.quantile_breaks(list(range(1, 101)), 4) assert len(br) == 3 and br[1] == pytest.approx(50.5) assert stats.quantile_breaks([1], 5) == [] h = stats.histogram([1, 2, 3, 4, 5], bins=5) assert sum(h["counts"]) == 5 and len(h["edges"]) == 6 hl = stats.histogram([1, 10, 100, 1000], bins=3, log=True) assert hl["log"] and hl["edges"][0] == pytest.approx(1.0) and hl["edges"][-1] == pytest.approx(1000.0) start = [10 + i for i in range(20)] end = [20 + i * 0.2 for i in range(20)] c = stats.convergence(start, end) assert c["direction"] == "converging" and c["n"] == 20 assert stats.convergence([1, 2], [1, 2]) is None