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
184 lines
5.5 KiB
Python
184 lines
5.5 KiB
Python
"""Unit tests for ferro_ta.indicators.cycle"""
|
|
|
|
import numpy as np
|
|
|
|
from ferro_ta.indicators.cycle import (
|
|
HT_DCPERIOD,
|
|
HT_DCPHASE,
|
|
HT_PHASOR,
|
|
HT_SINE,
|
|
HT_TRENDLINE,
|
|
HT_TRENDMODE,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixtures — cycle indicators need at least ~64 bars for valid output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
N = 200
|
|
t = np.linspace(0, 10 * np.pi, N)
|
|
SINE_CLOSE = 100 + 10 * np.sin(t) # clean sine wave
|
|
|
|
|
|
def _warmup_end(arr):
|
|
"""Return index of first non-NaN value (or N if all NaN)."""
|
|
valid = np.where(~np.isnan(arr.astype(float)))[0]
|
|
return valid[0] if len(valid) else N
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HT_DCPERIOD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHT_DCPERIOD:
|
|
def test_length(self):
|
|
result = HT_DCPERIOD(SINE_CLOSE)
|
|
assert len(result) == N
|
|
|
|
def test_nan_warmup(self):
|
|
result = HT_DCPERIOD(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
assert w > 0
|
|
assert np.all(np.isnan(result[:w]))
|
|
|
|
def test_valid_finite(self):
|
|
result = HT_DCPERIOD(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
assert np.all(np.isfinite(result[w:]))
|
|
|
|
def test_sine_period_reasonable(self):
|
|
# Our sine has period = 2*pi in t; with N=200 and t in [0,10*pi]
|
|
# the true period in samples = 200 / (10*pi / (2*pi)) = 200/5 = 40
|
|
result = HT_DCPERIOD(SINE_CLOSE)
|
|
valid = result[~np.isnan(result)]
|
|
# HT_DCPERIOD should detect a period in a reasonable range [6, 100]
|
|
assert np.any((valid > 6) & (valid < 100))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HT_DCPHASE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHT_DCPHASE:
|
|
def test_length(self):
|
|
assert len(HT_DCPHASE(SINE_CLOSE)) == N
|
|
|
|
def test_nan_warmup(self):
|
|
result = HT_DCPHASE(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
assert w > 0
|
|
|
|
def test_valid_finite(self):
|
|
result = HT_DCPHASE(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
assert np.all(np.isfinite(result[w:]))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HT_PHASOR
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHT_PHASOR:
|
|
def test_returns_two_arrays(self):
|
|
result = HT_PHASOR(SINE_CLOSE)
|
|
assert isinstance(result, tuple) and len(result) == 2
|
|
|
|
def test_length(self):
|
|
inphase, quadrature = HT_PHASOR(SINE_CLOSE)
|
|
assert len(inphase) == len(quadrature) == N
|
|
|
|
def test_nan_warmup(self):
|
|
inphase, quadrature = HT_PHASOR(SINE_CLOSE)
|
|
w = _warmup_end(inphase)
|
|
assert w > 0
|
|
|
|
def test_valid_finite(self):
|
|
inphase, quadrature = HT_PHASOR(SINE_CLOSE)
|
|
wi = _warmup_end(inphase)
|
|
wq = _warmup_end(quadrature)
|
|
assert np.all(np.isfinite(inphase[wi:]))
|
|
assert np.all(np.isfinite(quadrature[wq:]))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HT_SINE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHT_SINE:
|
|
def test_returns_two_arrays(self):
|
|
result = HT_SINE(SINE_CLOSE)
|
|
assert isinstance(result, tuple) and len(result) == 2
|
|
|
|
def test_length(self):
|
|
sine, leadsine = HT_SINE(SINE_CLOSE)
|
|
assert len(sine) == len(leadsine) == N
|
|
|
|
def test_nan_warmup(self):
|
|
sine, leadsine = HT_SINE(SINE_CLOSE)
|
|
w = _warmup_end(sine)
|
|
assert w > 0
|
|
|
|
def test_valid_finite(self):
|
|
sine, leadsine = HT_SINE(SINE_CLOSE)
|
|
ws = _warmup_end(sine)
|
|
wl = _warmup_end(leadsine)
|
|
assert np.all(np.isfinite(sine[ws:]))
|
|
assert np.all(np.isfinite(leadsine[wl:]))
|
|
|
|
def test_values_in_sine_range(self):
|
|
# Sine values should be in [-1, 1] roughly
|
|
sine, leadsine = HT_SINE(SINE_CLOSE)
|
|
valid = sine[~np.isnan(sine)]
|
|
assert np.all(valid >= -1.5) and np.all(valid <= 1.5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HT_TRENDLINE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHT_TRENDLINE:
|
|
def test_length(self):
|
|
assert len(HT_TRENDLINE(SINE_CLOSE)) == N
|
|
|
|
def test_nan_warmup(self):
|
|
result = HT_TRENDLINE(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
assert w > 0
|
|
|
|
def test_valid_finite(self):
|
|
result = HT_TRENDLINE(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
assert np.all(np.isfinite(result[w:]))
|
|
|
|
def test_smooth_trendline(self):
|
|
# Trendline should be smoother than raw close
|
|
result = HT_TRENDLINE(SINE_CLOSE)
|
|
w = _warmup_end(result)
|
|
raw_std = np.std(np.diff(SINE_CLOSE[w:]))
|
|
trend_std = np.std(np.diff(result[w:]))
|
|
assert trend_std < raw_std
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HT_TRENDMODE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHT_TRENDMODE:
|
|
def test_length(self):
|
|
assert len(HT_TRENDMODE(SINE_CLOSE)) == N
|
|
|
|
def test_values_binary(self):
|
|
result = HT_TRENDMODE(SINE_CLOSE)
|
|
assert np.all(np.isin(result, [0, 1]))
|
|
|
|
def test_nan_warmup_as_zero(self):
|
|
# HT_TRENDMODE returns integers (no NaN); warmup bars should be 0
|
|
result = HT_TRENDMODE(SINE_CLOSE)
|
|
assert np.all(np.isfinite(result.astype(float)))
|