Files
Pratik Bhadane 307beeca02 release: cut v1.0.0
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
2026-03-23 23:57:30 +05:30

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)))