Files
ferro-ta/tests/unit/indicators/test_momentum.py
T
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

589 lines
18 KiB
Python

"""Unit tests for ferro_ta.indicators.momentum"""
import numpy as np
from ferro_ta.indicators.momentum import (
ADX,
ADXR,
APO,
AROON,
AROONOSC,
BOP,
CCI,
CMO,
DX,
MFI,
MINUS_DI,
MINUS_DM,
MOM,
PLUS_DI,
PLUS_DM,
PPO,
ROC,
ROCP,
ROCR,
ROCR100,
RSI,
STOCH,
STOCHF,
STOCHRSI,
TRIX,
ULTOSC,
WILLR,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
RNG = np.random.default_rng(7)
N = 100
_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N))
_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N))
_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N))
_OPEN = _CLOSE + RNG.normal(0, 0.1, N)
_VOL = RNG.uniform(1000, 5000, N)
SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
SMALL5_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
SMALL5_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
SMALL5_O = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
SMALL5_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0])
# ---------------------------------------------------------------------------
# RSI
# ---------------------------------------------------------------------------
class TestRSI:
def test_nan_warmup(self):
result = RSI(_CLOSE, timeperiod=14)
assert np.all(np.isnan(result[:14]))
def test_range(self):
result = RSI(_CLOSE, timeperiod=14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
assert len(RSI(_CLOSE, 14)) == N
# ---------------------------------------------------------------------------
# STOCH
# ---------------------------------------------------------------------------
class TestSTOCH:
def test_returns_two_arrays(self):
result = STOCH(_HIGH, _LOW, _CLOSE)
assert isinstance(result, tuple) and len(result) == 2
def test_range(self):
slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE)
for arr in [slowk, slowd]:
valid = arr[~np.isnan(arr)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE)
assert len(slowk) == len(slowd) == N
# ---------------------------------------------------------------------------
# STOCHF
# ---------------------------------------------------------------------------
class TestSTOCHF:
def test_returns_two_arrays(self):
result = STOCHF(_HIGH, _LOW, _CLOSE)
assert isinstance(result, tuple) and len(result) == 2
def test_fastk_range(self):
fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE, fastk_period=5, fastd_period=3)
valid = fastk[~np.isnan(fastk)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_known_values(self):
# With identical OHLC, fast %K = 100 * (C - min_low) / (max_high - min_low)
# On our SMALL5 data the range is constant so all = 2/6 * 100 ≈ 66.67
h5 = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
l5 = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
c5 = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
fastk, fastd = STOCHF(h5, l5, c5, fastk_period=3, fastd_period=2)
valid_k = fastk[~np.isnan(fastk)]
assert np.all(valid_k >= 0) and np.all(valid_k <= 100)
def test_length(self):
fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE)
assert len(fastk) == len(fastd) == N
# ---------------------------------------------------------------------------
# STOCHRSI
# ---------------------------------------------------------------------------
class TestSTOCHRSI:
def test_returns_two_arrays(self):
result = STOCHRSI(_CLOSE)
assert isinstance(result, tuple) and len(result) == 2
def test_range(self):
fastk, fastd = STOCHRSI(_CLOSE, timeperiod=14)
for arr in [fastk, fastd]:
valid = arr[~np.isnan(arr)]
assert np.all(valid >= -1e-10) and np.all(valid <= 100 + 1e-10)
def test_length(self):
fastk, fastd = STOCHRSI(_CLOSE)
assert len(fastk) == N
# ---------------------------------------------------------------------------
# ADX
# ---------------------------------------------------------------------------
class TestADX:
def test_nan_warmup(self):
result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
assert np.all(np.isnan(result[:27]))
def test_range(self):
result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
assert len(ADX(_HIGH, _LOW, _CLOSE, 14)) == N
# ---------------------------------------------------------------------------
# ADXR
# ---------------------------------------------------------------------------
class TestADXR:
def test_length(self):
assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N
def test_range(self):
result = ADXR(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0) and np.all(valid <= 100)
# ---------------------------------------------------------------------------
# CCI
# ---------------------------------------------------------------------------
class TestCCI:
def test_known_constant_mean_dev(self):
# Constant typical price → CCI = 0 after warmup
c5 = np.full(10, 12.0)
h5 = np.full(10, 13.0)
l5 = np.full(10, 11.0)
result = CCI(h5, l5, c5, timeperiod=5)
valid = result[~np.isnan(result)]
np.testing.assert_allclose(valid, 0.0, atol=1e-10)
def test_length(self):
assert len(CCI(_HIGH, _LOW, _CLOSE, 14)) == N
def test_nan_warmup(self):
result = CCI(_HIGH, _LOW, _CLOSE, timeperiod=14)
assert np.all(np.isnan(result[:13]))
def test_simple_rising(self):
h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
l = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
c = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
result = CCI(h, l, c, 3)
valid = result[~np.isnan(result)]
np.testing.assert_allclose(valid, 100.0, atol=1e-8)
# ---------------------------------------------------------------------------
# WILLR
# ---------------------------------------------------------------------------
class TestWILLR:
def test_range(self):
result = WILLR(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= -100) and np.all(valid <= 0)
def test_length(self):
assert len(WILLR(_HIGH, _LOW, _CLOSE, 14)) == N
# ---------------------------------------------------------------------------
# AROON
# ---------------------------------------------------------------------------
class TestAROON:
def test_returns_two_arrays(self):
result = AROON(_HIGH, _LOW, 14)
assert isinstance(result, tuple) and len(result) == 2
def test_range(self):
aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
for arr in [aroon_down, aroon_up]:
valid = arr[~np.isnan(arr)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
assert len(aroon_down) == N
# ---------------------------------------------------------------------------
# AROONOSC
# ---------------------------------------------------------------------------
class TestAROONOSC:
def test_known_values(self):
h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
l = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
result = AROONOSC(h, l, timeperiod=2)
valid = result[~np.isnan(result)]
# Monotone rising high/low → aroon_up = 100, aroon_down = 0 → osc = 100
np.testing.assert_allclose(valid, 100.0, atol=1e-10)
def test_equals_aroon_diff(self):
aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
aroonosc = AROONOSC(_HIGH, _LOW, 14)
valid = ~np.isnan(aroon_up) & ~np.isnan(aroon_down) & ~np.isnan(aroonosc)
np.testing.assert_allclose(
aroonosc[valid],
aroon_up[valid] - aroon_down[valid],
atol=1e-10,
)
def test_length(self):
assert len(AROONOSC(_HIGH, _LOW, 14)) == N
# ---------------------------------------------------------------------------
# MFI
# ---------------------------------------------------------------------------
class TestMFI:
def test_range(self):
result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
assert len(MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)) == N
def test_nan_warmup(self):
result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
assert np.all(np.isnan(result[:14]))
def test_constant_price_is_50(self):
# When money flow is neither positive nor negative → MFI should be near 50
# Use alternating tiny moves around constant so no clear direction
c = np.full(20, 100.0)
h = np.full(20, 101.0)
l = np.full(20, 99.0)
v = np.full(20, 1000.0)
result = MFI(h, l, c, v, 5)
valid = result[~np.isnan(result)]
assert len(valid) > 0 # just ensure it runs
# ---------------------------------------------------------------------------
# MOM
# ---------------------------------------------------------------------------
class TestMOM:
def test_known_values(self):
result = MOM(SMALL5, timeperiod=2)
assert np.isnan(result[0]) and np.isnan(result[1])
np.testing.assert_allclose(result[2], 2.0, rtol=1e-10)
np.testing.assert_allclose(result[3], 2.0, rtol=1e-10)
def test_length(self):
assert len(MOM(_CLOSE, 10)) == N
# ---------------------------------------------------------------------------
# ROC
# ---------------------------------------------------------------------------
class TestROC:
def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
result = ROC(arr, 2)
# ROC = ((close - close[n]) / close[n]) * 100
np.testing.assert_allclose(result[2], (12 - 10) / 10 * 100, rtol=1e-10)
def test_length(self):
assert len(ROC(_CLOSE, 10)) == N
# ---------------------------------------------------------------------------
# ROCP
# ---------------------------------------------------------------------------
class TestROCP:
def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
result = ROCP(arr, 2)
# ROCP = (close - close[n]) / close[n]
np.testing.assert_allclose(result[2], (12 - 10) / 10, rtol=1e-10)
def test_length(self):
assert len(ROCP(_CLOSE, 10)) == N
# ---------------------------------------------------------------------------
# ROCR
# ---------------------------------------------------------------------------
class TestROCR:
def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
result = ROCR(arr, 2)
# ROCR = close / close[n]
np.testing.assert_allclose(result[2], 12 / 10, rtol=1e-10)
np.testing.assert_allclose(result[4], 14 / 12, rtol=1e-10)
def test_nan_warmup(self):
result = ROCR(_CLOSE, 10)
assert np.all(np.isnan(result[:10]))
def test_length(self):
assert len(ROCR(_CLOSE, 10)) == N
# ---------------------------------------------------------------------------
# ROCR100
# ---------------------------------------------------------------------------
class TestROCR100:
def test_known_values(self):
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
result = ROCR100(arr, 2)
# ROCR100 = (close / close[n]) * 100
np.testing.assert_allclose(result[2], 12 / 10 * 100, rtol=1e-10)
def test_relation_to_rocr(self):
rocr = ROCR(_CLOSE, 5)
rocr100 = ROCR100(_CLOSE, 5)
valid = ~np.isnan(rocr)
np.testing.assert_allclose(rocr100[valid], rocr[valid] * 100, rtol=1e-10)
def test_length(self):
assert len(ROCR100(_CLOSE, 10)) == N
# ---------------------------------------------------------------------------
# CMO
# ---------------------------------------------------------------------------
class TestCMO:
def test_range(self):
result = CMO(_CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= -100) and np.all(valid <= 100)
def test_length(self):
assert len(CMO(_CLOSE, 14)) == N
# ---------------------------------------------------------------------------
# DX
# ---------------------------------------------------------------------------
class TestDX:
def test_range(self):
result = DX(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
assert len(DX(_HIGH, _LOW, _CLOSE, 14)) == N
# ---------------------------------------------------------------------------
# MINUS_DI / MINUS_DM
# ---------------------------------------------------------------------------
class TestMINUS:
def test_minus_di_range(self):
result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0)
def test_minus_dm_range(self):
result = MINUS_DM(_HIGH, _LOW, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0)
def test_lengths(self):
assert len(MINUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N
assert len(MINUS_DM(_HIGH, _LOW, 14)) == N
# ---------------------------------------------------------------------------
# PLUS_DI / PLUS_DM
# ---------------------------------------------------------------------------
class TestPLUS:
def test_plus_di_range(self):
result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0)
def test_plus_dm_range(self):
result = PLUS_DM(_HIGH, _LOW, 14)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0)
def test_lengths(self):
assert len(PLUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N
assert len(PLUS_DM(_HIGH, _LOW, 14)) == N
# ---------------------------------------------------------------------------
# PPO
# ---------------------------------------------------------------------------
class TestPPO:
def test_returns_three_arrays(self):
result = PPO(_CLOSE, fastperiod=12, slowperiod=26)
assert isinstance(result, tuple) and len(result) == 3
def test_histogram_is_diff(self):
ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26)
valid = ~np.isnan(ppo) & ~np.isnan(signal)
np.testing.assert_allclose(hist[valid], ppo[valid] - signal[valid], atol=1e-10)
def test_length(self):
ppo, signal, hist = PPO(_CLOSE)
assert len(ppo) == len(signal) == len(hist) == N
def test_nan_warmup(self):
ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26)
assert np.any(np.isnan(ppo))
# ---------------------------------------------------------------------------
# APO
# ---------------------------------------------------------------------------
class TestAPO:
def test_known_direction(self):
# Rising close → fast EMA > slow EMA → APO > 0 after warmup
rising = np.linspace(1.0, 100.0, 60)
result = APO(rising, fastperiod=5, slowperiod=10)
valid = result[~np.isnan(result)]
assert np.all(valid > 0)
def test_length(self):
assert len(APO(_CLOSE, 12, 26)) == N
def test_nan_warmup(self):
result = APO(_CLOSE, 12, 26)
assert np.any(np.isnan(result))
# ---------------------------------------------------------------------------
# TRIX
# ---------------------------------------------------------------------------
class TestTRIX:
def test_length(self):
assert len(TRIX(_CLOSE, 10)) == N
def test_nan_warmup(self):
result = TRIX(_CLOSE, timeperiod=5)
# TRIX warmup = 3*(tp-1) for triple EMA + 1 for diff
assert np.all(np.isnan(result[:12]))
def test_finite_after_warmup(self):
result = TRIX(_CLOSE, timeperiod=5)
valid = result[~np.isnan(result)]
assert np.all(np.isfinite(valid))
def test_rising_series_positive(self):
rising = np.linspace(1.0, 200.0, 100)
result = TRIX(rising, timeperiod=5)
valid = result[~np.isnan(result)]
# On monotone rise, rate of change of triple EMA is positive
assert np.all(valid > 0)
# ---------------------------------------------------------------------------
# BOP
# ---------------------------------------------------------------------------
class TestBOP:
def test_known_values(self):
o = np.array([10.0, 11.0])
h = np.array([14.0, 15.0])
l = np.array([8.0, 9.0])
c = np.array([12.0, 13.0])
# BOP = (close - open) / (high - low)
result = BOP(o, h, l, c)
np.testing.assert_allclose(result[0], (12 - 10) / (14 - 8), rtol=1e-10)
np.testing.assert_allclose(result[1], (13 - 11) / (15 - 9), rtol=1e-10)
def test_bearish_is_negative(self):
o = np.array([14.0, 14.0])
h = np.array([15.0, 15.0])
l = np.array([8.0, 8.0])
c = np.array([10.0, 10.0])
result = BOP(o, h, l, c)
assert np.all(result < 0)
def test_range(self):
# BOP = (close - open) / (high - low); can exceed [-1,1] with noisy data
result = BOP(_OPEN, _HIGH, _LOW, _CLOSE)
assert np.all(np.isfinite(result))
def test_length(self):
assert len(BOP(_OPEN, _HIGH, _LOW, _CLOSE)) == N
# ---------------------------------------------------------------------------
# ULTOSC
# ---------------------------------------------------------------------------
class TestULTOSC:
def test_range(self):
result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0) and np.all(valid <= 100)
def test_length(self):
assert len(ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)) == N
def test_nan_warmup(self):
result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
assert np.any(np.isnan(result))