307beeca02
Prepare the first public 1.0.0 release and finish the remaining CI hardening work. Highlights: - align Python, Rust, WASM, Conda, API, MCP, and docs version metadata to 1.0.0 - promote package metadata to Production/Stable and update stability/versioning docs for the stable series - move the accumulated Unreleased notes into a dated 1.0.0 changelog section and keep a fresh top-level Unreleased block - strengthen the changelog checker so it validates a single top-level Unreleased section - fix the CI/package support mismatch by declaring Python >=3.10 consistently and gating pandas-ta extras to Python 3.12+ - restore Sphinx autodoc compatibility for documented ferro_ta.<module> imports by registering module aliases - make the TA-Lib benchmark guardrail less flaky by checking median and tail-percentile speedups instead of failing on a single mild outlier - switch PyPI publishing to OIDC-only trusted publishing and wire the changelog check into the required CI gate - apply the Ruff-driven cleanup across the Python and test tree and refresh uv/cargo lockfiles Validated locally: - python3 scripts/check_changelog.py - uv run --with ruff ruff check python tests - uv run --with ruff ruff format --check python tests - uv lock --check - sphinx-build -b html docs docs/_build -W --keep-going - build/install the ferro_ta 1.0.0 wheel successfully
229 lines
7.1 KiB
Python
229 lines
7.1 KiB
Python
"""Unit tests for ferro_ta.indicators.statistic"""
|
|
|
|
import numpy as np
|
|
|
|
from ferro_ta.indicators.statistic import (
|
|
BETA,
|
|
CORREL,
|
|
LINEARREG,
|
|
LINEARREG_ANGLE,
|
|
LINEARREG_INTERCEPT,
|
|
LINEARREG_SLOPE,
|
|
STDDEV,
|
|
TSF,
|
|
VAR,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
RNG = np.random.default_rng(11)
|
|
N = 100
|
|
_A = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
|
_B = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
|
|
|
LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5]
|
|
CONSTDATA = np.ones(10) # all 1.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# STDDEV
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSTDDEV:
|
|
def test_constant_is_zero(self):
|
|
result = STDDEV(CONSTDATA, timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
np.testing.assert_allclose(valid, 0.0, atol=1e-10)
|
|
|
|
def test_known_values(self):
|
|
# std([1,2,3,4,5], ddof=0) = sqrt(2)
|
|
result = STDDEV(LINDATA, timeperiod=5)
|
|
np.testing.assert_allclose(result[4], np.sqrt(2.0), rtol=1e-6)
|
|
|
|
def test_nan_warmup(self):
|
|
result = STDDEV(_A, timeperiod=5)
|
|
assert np.all(np.isnan(result[:4]))
|
|
|
|
def test_length(self):
|
|
assert len(STDDEV(_A, 5)) == N
|
|
|
|
def test_positive(self):
|
|
result = STDDEV(_A, 5)
|
|
valid = result[~np.isnan(result)]
|
|
assert np.all(valid >= 0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# VAR
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVAR:
|
|
def test_constant_is_zero(self):
|
|
result = VAR(CONSTDATA, timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
np.testing.assert_allclose(valid, 0.0, atol=1e-10)
|
|
|
|
def test_known_values(self):
|
|
# var([1,2,3,4,5], ddof=0) = 2.0
|
|
result = VAR(LINDATA, timeperiod=5)
|
|
np.testing.assert_allclose(result[4], 2.0, rtol=1e-6)
|
|
|
|
def test_equals_stddev_squared(self):
|
|
std = STDDEV(_A, timeperiod=10)
|
|
var = VAR(_A, timeperiod=10)
|
|
valid = ~np.isnan(std) & ~np.isnan(var)
|
|
np.testing.assert_allclose(var[valid], std[valid] ** 2, rtol=1e-6)
|
|
|
|
def test_length(self):
|
|
assert len(VAR(_A, 5)) == N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LINEARREG
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLINEARREG:
|
|
def test_perfect_line(self):
|
|
# For [1,2,3,4,5] over window 5, forecast = 5.0
|
|
result = LINEARREG(LINDATA, timeperiod=5)
|
|
np.testing.assert_allclose(result[4], 5.0, rtol=1e-10)
|
|
|
|
def test_nan_warmup(self):
|
|
result = LINEARREG(_A, timeperiod=14)
|
|
assert np.all(np.isnan(result[:13]))
|
|
|
|
def test_length(self):
|
|
assert len(LINEARREG(_A, 14)) == N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LINEARREG_SLOPE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLINEARREG_SLOPE:
|
|
def test_perfect_line_slope_one(self):
|
|
result = LINEARREG_SLOPE(LINDATA, timeperiod=5)
|
|
np.testing.assert_allclose(result[4], 1.0, rtol=1e-10)
|
|
|
|
def test_constant_slope_zero(self):
|
|
result = LINEARREG_SLOPE(CONSTDATA, timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
np.testing.assert_allclose(valid, 0.0, atol=1e-10)
|
|
|
|
def test_length(self):
|
|
assert len(LINEARREG_SLOPE(_A, 14)) == N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LINEARREG_INTERCEPT
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLINEARREG_INTERCEPT:
|
|
def test_perfect_line_intercept_one(self):
|
|
# y = [1,2,3,4,5] with x=[0,1,2,3,4] → y = 1 + 1*x → intercept = 1.0
|
|
result = LINEARREG_INTERCEPT(LINDATA, timeperiod=5)
|
|
np.testing.assert_allclose(result[4], 1.0, atol=1e-10)
|
|
|
|
def test_length(self):
|
|
assert len(LINEARREG_INTERCEPT(_A, 14)) == N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LINEARREG_ANGLE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLINEARREG_ANGLE:
|
|
def test_slope_one_gives_45_degrees(self):
|
|
result = LINEARREG_ANGLE(LINDATA, timeperiod=5)
|
|
# arctan(1) * 180/pi = 45
|
|
np.testing.assert_allclose(result[4], 45.0, rtol=1e-6)
|
|
|
|
def test_constant_gives_zero_degrees(self):
|
|
result = LINEARREG_ANGLE(CONSTDATA, timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
np.testing.assert_allclose(valid, 0.0, atol=1e-8)
|
|
|
|
def test_length(self):
|
|
assert len(LINEARREG_ANGLE(_A, 14)) == N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BETA
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBETA:
|
|
def test_nan_warmup(self):
|
|
result = BETA(_A, _B, timeperiod=5)
|
|
assert np.all(np.isnan(result[:4]))
|
|
|
|
def test_length(self):
|
|
assert len(BETA(_A, _B, 5)) == N
|
|
|
|
def test_same_series(self):
|
|
# Beta of x vs x = 1.0 (regression of itself)
|
|
result = BETA(_A, _A, timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
assert np.all(np.isfinite(valid))
|
|
|
|
def test_finite_after_warmup(self):
|
|
result = BETA(_A, _B, timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
assert np.all(np.isfinite(valid))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CORREL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCOREL:
|
|
def test_self_correlation_is_one(self):
|
|
result = CORREL(_A, _A, timeperiod=10)
|
|
valid = result[~np.isnan(result)]
|
|
np.testing.assert_allclose(valid, 1.0, atol=1e-10)
|
|
|
|
def test_opposite_correlation_is_minus_one(self):
|
|
arr = np.arange(1.0, 11.0)
|
|
result = CORREL(arr, arr[::-1], timeperiod=5)
|
|
valid = result[~np.isnan(result)]
|
|
np.testing.assert_allclose(valid, -1.0, atol=1e-10)
|
|
|
|
def test_range(self):
|
|
result = CORREL(_A, _B, timeperiod=10)
|
|
valid = result[~np.isnan(result)]
|
|
assert np.all(valid >= -1 - 1e-10) and np.all(valid <= 1 + 1e-10)
|
|
|
|
def test_length(self):
|
|
assert len(CORREL(_A, _B, 10)) == N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TSF
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTSF:
|
|
def test_perfect_line(self):
|
|
arr = np.arange(1.0, 10.0)
|
|
result = TSF(arr, timeperiod=3)
|
|
# TSF(3) on [1,2,...] = linear forecast one period ahead
|
|
# Over window [1,2,3]: slope=1, intercept=0 → forecast at bar 2+1=3 → TSF[2]=4
|
|
np.testing.assert_allclose(result[2], 4.0, rtol=1e-10)
|
|
np.testing.assert_allclose(result[3], 5.0, rtol=1e-10)
|
|
|
|
def test_nan_warmup(self):
|
|
result = TSF(_A, timeperiod=14)
|
|
assert np.all(np.isnan(result[:13]))
|
|
|
|
def test_length(self):
|
|
assert len(TSF(_A, 14)) == N
|