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
589 lines
18 KiB
Python
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))
|