feat: init the repo
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Unit test conftest — inherits shared fixtures from tests/conftest.py.
|
||||
|
||||
pytest automatically loads parent conftest.py files, so all fixtures
|
||||
defined in tests/conftest.py (ohlcv_500, ohlcv_100, ohlcv_real) are
|
||||
available here without any explicit import.
|
||||
"""
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Unit tests for ferro_ta.indicators.cycle"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
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)))
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Unit tests for ferro_ta.indicators.extended"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.extended import (
|
||||
VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS,
|
||||
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(99)
|
||||
N = 200
|
||||
_C = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
||||
_H = _C + np.abs(RNG.normal(0, 0.3, N))
|
||||
_L = _C - np.abs(RNG.normal(0, 0.3, N))
|
||||
_O = _C + RNG.normal(0, 0.1, N)
|
||||
_VOL = RNG.uniform(1000, 5000, N)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VWAP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVWAP:
|
||||
def test_length(self):
|
||||
result = VWAP(_H, _L, _C, _VOL)
|
||||
assert len(result) == N
|
||||
|
||||
def test_no_nan(self):
|
||||
result = VWAP(_H, _L, _C, _VOL)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_positive(self):
|
||||
result = VWAP(_H, _L, _C, _VOL)
|
||||
assert np.all(result > 0)
|
||||
|
||||
def test_windowed(self):
|
||||
result = VWAP(_H, _L, _C, _VOL, timeperiod=20)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SUPERTREND
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSUPERTREND:
|
||||
def test_returns_two_arrays(self):
|
||||
result = SUPERTREND(_H, _L, _C)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_length(self):
|
||||
trend, direction = SUPERTREND(_H, _L, _C)
|
||||
assert len(trend) == len(direction) == N
|
||||
|
||||
def test_direction_binary(self):
|
||||
trend, direction = SUPERTREND(_H, _L, _C)
|
||||
valid = direction[~np.isnan(direction.astype(float))]
|
||||
assert np.all(np.isin(valid, [-1, 0, 1]))
|
||||
|
||||
def test_nan_warmup(self):
|
||||
trend, direction = SUPERTREND(_H, _L, _C, timeperiod=7)
|
||||
assert np.any(np.isnan(trend))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ICHIMOKU
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestICHIMOKU:
|
||||
def test_returns_five_arrays(self):
|
||||
result = ICHIMOKU(_H, _L, _C)
|
||||
assert isinstance(result, tuple) and len(result) == 5
|
||||
|
||||
def test_length(self):
|
||||
result = ICHIMOKU(_H, _L, _C)
|
||||
for arr in result:
|
||||
assert len(arr) == N
|
||||
|
||||
def test_tenkan_warmup(self):
|
||||
tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(_H, _L, _C, tenkan_period=9)
|
||||
assert np.all(np.isnan(tenkan[:8]))
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(_H, _L, _C)
|
||||
for arr in [tenkan, kijun]:
|
||||
valid = arr[~np.isnan(arr)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DONCHIAN
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDONCHIAN:
|
||||
def test_returns_three_arrays(self):
|
||||
result = DONCHIAN(_H, _L)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_length(self):
|
||||
upper, middle, lower = DONCHIAN(_H, _L)
|
||||
assert len(upper) == len(middle) == len(lower) == N
|
||||
|
||||
def test_upper_ge_lower(self):
|
||||
upper, middle, lower = DONCHIAN(_H, _L)
|
||||
valid = ~np.isnan(upper) & ~np.isnan(lower)
|
||||
assert np.all(upper[valid] >= lower[valid])
|
||||
|
||||
def test_middle_is_average(self):
|
||||
upper, middle, lower = DONCHIAN(_H, _L)
|
||||
valid = ~np.isnan(upper) & ~np.isnan(lower) & ~np.isnan(middle)
|
||||
np.testing.assert_allclose(
|
||||
middle[valid],
|
||||
(upper[valid] + lower[valid]) / 2.0,
|
||||
rtol=1e-10,
|
||||
)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
upper, middle, lower = DONCHIAN(_H, _L, timeperiod=20)
|
||||
assert np.all(np.isnan(upper[:19]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PIVOT_POINTS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPIVOT_POINTS:
|
||||
def test_returns_five_arrays(self):
|
||||
result = PIVOT_POINTS(_H, _L, _C)
|
||||
assert isinstance(result, tuple) and len(result) == 5
|
||||
|
||||
def test_length(self):
|
||||
result = PIVOT_POINTS(_H, _L, _C)
|
||||
for arr in result:
|
||||
assert len(arr) == N
|
||||
|
||||
def test_classic_pivot_formula(self):
|
||||
# PP = (H + L + C) / 3
|
||||
pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C, method='classic')
|
||||
valid = ~np.isnan(pp)
|
||||
expected_pp = (_H[:-1] + _L[:-1] + _C[:-1]) / 3.0
|
||||
np.testing.assert_allclose(pp[valid], expected_pp[valid[1:]], rtol=1e-6)
|
||||
|
||||
def test_first_is_nan(self):
|
||||
pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C)
|
||||
assert np.isnan(pp[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KELTNER_CHANNELS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKELTNER_CHANNELS:
|
||||
def test_returns_three_arrays(self):
|
||||
result = KELTNER_CHANNELS(_H, _L, _C)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_length(self):
|
||||
upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C)
|
||||
assert len(upper) == len(middle) == len(lower) == N
|
||||
|
||||
def test_upper_gt_lower(self):
|
||||
upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C)
|
||||
valid = ~np.isnan(upper) & ~np.isnan(lower)
|
||||
assert np.all(upper[valid] > lower[valid])
|
||||
|
||||
def test_nan_warmup(self):
|
||||
upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C, timeperiod=20)
|
||||
assert np.all(np.isnan(upper[:19]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HULL_MA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHULL_MA:
|
||||
def test_length(self):
|
||||
assert len(HULL_MA(_C, timeperiod=16)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = HULL_MA(_C, timeperiod=16)
|
||||
assert np.all(np.isnan(result[:18]))
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = HULL_MA(_C, timeperiod=16)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_tracks_trend(self):
|
||||
rising = np.linspace(10.0, 200.0, 200)
|
||||
result = HULL_MA(rising, timeperiod=16)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.diff(valid) > 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CHANDELIER_EXIT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCHANDELIER_EXIT:
|
||||
def test_returns_two_arrays(self):
|
||||
result = CHANDELIER_EXIT(_H, _L, _C)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_length(self):
|
||||
long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C)
|
||||
assert len(long_stop) == len(short_stop) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C, timeperiod=22)
|
||||
assert np.all(np.isnan(long_stop[:21]))
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C, timeperiod=22)
|
||||
for arr in [long_stop, short_stop]:
|
||||
valid = arr[~np.isnan(arr)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VWMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVWMA:
|
||||
def test_length(self):
|
||||
assert len(VWMA(_C, _VOL, timeperiod=20)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = VWMA(_C, _VOL, timeperiod=20)
|
||||
assert np.all(np.isnan(result[:19]))
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = VWMA(_C, _VOL, timeperiod=20)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_constant_volume_equals_sma(self):
|
||||
# When all volumes are equal, VWMA = SMA
|
||||
vol = np.ones(N) * 1000.0
|
||||
vwma = VWMA(_C, vol, timeperiod=20)
|
||||
from ferro_ta.indicators.overlap import SMA
|
||||
sma = SMA(_C, timeperiod=20)
|
||||
valid = ~np.isnan(vwma) & ~np.isnan(sma)
|
||||
np.testing.assert_allclose(vwma[valid], sma[valid], rtol=1e-8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CHOPPINESS_INDEX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCHOPPINESS_INDEX:
|
||||
def test_length(self):
|
||||
assert len(CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:14]))
|
||||
|
||||
def test_range(self):
|
||||
# Choppiness index is bounded between 0 and 100
|
||||
result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid > 0) and np.all(valid < 200)
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Unit tests for ferro_ta.indicators.math_ops"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.math_ops import (
|
||||
ADD, SUB, MULT, DIV, SUM, MAX, MIN, MAXINDEX, MININDEX,
|
||||
ACOS, ASIN, ATAN, CEIL, COS, COSH, EXP, FLOOR,
|
||||
LN, LOG10, SIN, SINH, SQRT, TAN, TANH,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
A3 = np.array([1.0, 2.0, 3.0])
|
||||
B3 = np.array([4.0, 5.0, 6.0])
|
||||
TRIG = np.array([0.0, np.pi / 6, np.pi / 4, np.pi / 3, np.pi / 2])
|
||||
UNIT = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) # values in [0,1] for ASIN/ACOS
|
||||
|
||||
RNG = np.random.default_rng(17)
|
||||
N = 100
|
||||
_ARR = 1.0 + RNG.random(N) * 9.0 # positive values in (1, 10]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestADD:
|
||||
def test_known_values(self):
|
||||
result = ADD(A3, B3)
|
||||
np.testing.assert_allclose(result, [5.0, 7.0, 9.0], rtol=1e-10)
|
||||
|
||||
def test_commutative(self):
|
||||
np.testing.assert_allclose(ADD(A3, B3), ADD(B3, A3), rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(ADD(_ARR, _ARR)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SUB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSUB:
|
||||
def test_known_values(self):
|
||||
result = SUB(B3, A3)
|
||||
np.testing.assert_allclose(result, [3.0, 3.0, 3.0], rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(SUB(_ARR, _ARR)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MULT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMULT:
|
||||
def test_known_values(self):
|
||||
result = MULT(A3, B3)
|
||||
np.testing.assert_allclose(result, [4.0, 10.0, 18.0], rtol=1e-10)
|
||||
|
||||
def test_commutative(self):
|
||||
np.testing.assert_allclose(MULT(A3, B3), MULT(B3, A3), rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MULT(_ARR, _ARR)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DIV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDIV:
|
||||
def test_known_values(self):
|
||||
result = DIV(B3, A3)
|
||||
np.testing.assert_allclose(result, [4.0, 2.5, 2.0], rtol=1e-10)
|
||||
|
||||
def test_self_division_is_one(self):
|
||||
np.testing.assert_allclose(DIV(_ARR, _ARR), np.ones(N), rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(DIV(_ARR, _ARR)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SUM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSUM:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
result = SUM(arr, timeperiod=3)
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], 6.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[4], 12.0, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = SUM(_ARR, timeperiod=5)
|
||||
assert np.all(np.isnan(result[:4]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(SUM(_ARR, 5)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMAX:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.0, 3.0, 2.0, 5.0, 4.0])
|
||||
result = MAX(arr, timeperiod=3)
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], 3.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[3], 5.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[4], 5.0, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = MAX(_ARR, timeperiod=5)
|
||||
assert np.all(np.isnan(result[:4]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(MAX(_ARR, 5)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIN
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMIN:
|
||||
def test_known_values(self):
|
||||
arr = np.array([5.0, 3.0, 4.0, 1.0, 2.0])
|
||||
result = MIN(arr, timeperiod=3)
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], 3.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[3], 1.0, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MIN(_ARR, 5)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAXINDEX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMAXINDEX:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.0, 5.0, 3.0, 2.0, 4.0])
|
||||
result = MAXINDEX(arr, timeperiod=3)
|
||||
# warmup entries are -1 (sentinel for "no data")
|
||||
assert result[0] < 0 and result[1] < 0
|
||||
# window[0:3] = [1,5,3] → max at local index 1 → absolute index 1
|
||||
np.testing.assert_allclose(result[2], 1.0, rtol=1e-10)
|
||||
# window[2:5] = [3,2,4] → max at local index 2 → absolute index 4
|
||||
np.testing.assert_allclose(result[4], 4.0, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MAXINDEX(_ARR, 5)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MININDEX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMININDEX:
|
||||
def test_known_values(self):
|
||||
arr = np.array([5.0, 1.0, 3.0, 2.0, 4.0])
|
||||
result = MININDEX(arr, timeperiod=3)
|
||||
# warmup entries are -1 (sentinel for "no data")
|
||||
assert result[0] < 0 and result[1] < 0
|
||||
# window[0:3] = [5,1,3] → min at local index 1 → absolute index 1
|
||||
np.testing.assert_allclose(result[2], 1.0, rtol=1e-10)
|
||||
# window[2:5] = [3,2,4] → min at local index 1 → absolute index 3
|
||||
np.testing.assert_allclose(result[4], 3.0, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MININDEX(_ARR, 5)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trig functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSIN:
|
||||
def test_known_values(self):
|
||||
angles = np.array([0.0, np.pi / 2, np.pi])
|
||||
result = SIN(angles)
|
||||
np.testing.assert_allclose(result, np.sin(angles), atol=1e-10)
|
||||
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(SIN(TRIG), np.sin(TRIG), rtol=1e-10)
|
||||
|
||||
|
||||
class TestCOS:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(COS(TRIG), np.cos(TRIG), rtol=1e-10)
|
||||
|
||||
|
||||
class TestTAN:
|
||||
def test_matches_numpy(self):
|
||||
safe = np.array([0.0, 0.5, 1.0])
|
||||
np.testing.assert_allclose(TAN(safe), np.tan(safe), rtol=1e-10)
|
||||
|
||||
|
||||
class TestASIN:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(ASIN(UNIT), np.arcsin(UNIT), rtol=1e-10)
|
||||
|
||||
|
||||
class TestACOS:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(ACOS(UNIT), np.arccos(UNIT), rtol=1e-10)
|
||||
|
||||
|
||||
class TestATAN:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(ATAN(TRIG), np.arctan(TRIG), rtol=1e-10)
|
||||
|
||||
|
||||
class TestSINH:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(SINH(A3), np.sinh(A3), rtol=1e-10)
|
||||
|
||||
|
||||
class TestCOSH:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(COSH(A3), np.cosh(A3), rtol=1e-10)
|
||||
|
||||
|
||||
class TestTANH:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(TANH(UNIT), np.tanh(UNIT), rtol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rounding/exponential
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCEIL:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.1, 2.5, 3.9, -0.5])
|
||||
np.testing.assert_allclose(CEIL(arr), np.ceil(arr), rtol=1e-10)
|
||||
|
||||
|
||||
class TestFLOOR:
|
||||
def test_known_values(self):
|
||||
arr = np.array([1.1, 2.5, 3.9, -0.5])
|
||||
np.testing.assert_allclose(FLOOR(arr), np.floor(arr), rtol=1e-10)
|
||||
|
||||
|
||||
class TestEXP:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(EXP(A3), np.exp(A3), rtol=1e-10)
|
||||
|
||||
def test_exp_zero_is_one(self):
|
||||
np.testing.assert_allclose(EXP(np.array([0.0])), [1.0], rtol=1e-10)
|
||||
|
||||
|
||||
class TestLN:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(LN(_ARR), np.log(_ARR), rtol=1e-10)
|
||||
|
||||
def test_ln_exp_inverse(self):
|
||||
np.testing.assert_allclose(LN(EXP(A3)), A3, rtol=1e-10)
|
||||
|
||||
|
||||
class TestLOG10:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(LOG10(_ARR), np.log10(_ARR), rtol=1e-10)
|
||||
|
||||
def test_log10_of_100_is_2(self):
|
||||
np.testing.assert_allclose(LOG10(np.array([100.0])), [2.0], rtol=1e-10)
|
||||
|
||||
|
||||
class TestSQRT:
|
||||
def test_matches_numpy(self):
|
||||
np.testing.assert_allclose(SQRT(_ARR), np.sqrt(_ARR), rtol=1e-10)
|
||||
|
||||
def test_sqrt_of_4_is_2(self):
|
||||
np.testing.assert_allclose(SQRT(np.array([4.0])), [2.0], rtol=1e-10)
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Unit tests for ferro_ta.indicators.momentum"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.momentum import (
|
||||
RSI, STOCH, STOCHF, STOCHRSI,
|
||||
ADX, ADXR, CCI, WILLR, AROON, AROONOSC,
|
||||
MFI, MOM, ROC, ROCP, ROCR, ROCR100,
|
||||
CMO, DX, MINUS_DI, MINUS_DM, PLUS_DI, PLUS_DM,
|
||||
PPO, APO, TRIX, ULTOSC, BOP,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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))
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Unit tests for ferro_ta.indicators.overlap"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.overlap import (
|
||||
SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MA,
|
||||
MACD, MACDFIX, MACDEXT, BBANDS, SAR, SAREXT,
|
||||
MAMA, MAVP, MIDPOINT, MIDPRICE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(42)
|
||||
N = 200
|
||||
_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))
|
||||
|
||||
SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
SMALL5_HIGH = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
SMALL5_LOW = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSMA:
|
||||
def test_known_values(self):
|
||||
result = SMA(SMALL5, timeperiod=3)
|
||||
expected = np.array([np.nan, np.nan, 11.0, 12.0, 13.0])
|
||||
np.testing.assert_allclose(result[2:], expected[2:], rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = SMA(SMALL5, timeperiod=3)
|
||||
assert np.all(np.isnan(result[:2]))
|
||||
|
||||
def test_length(self):
|
||||
result = SMA(_CLOSE, timeperiod=20)
|
||||
assert len(result) == N
|
||||
|
||||
def test_nan_warmup_long(self):
|
||||
result = SMA(_CLOSE, timeperiod=20)
|
||||
assert np.all(np.isnan(result[:19]))
|
||||
assert np.all(np.isfinite(result[19:]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEMA:
|
||||
def test_known_values(self):
|
||||
# k = 2/(3+1) = 0.5; seed = SMA(3) = 11.0
|
||||
# EMA[2] = SMA([10,11,12]) = 11.0
|
||||
# EMA[3] = close[3]*k + EMA[2]*(1-k) = 13*0.5 + 11.0*0.5 = 12.0
|
||||
# EMA[4] = close[4]*k + EMA[3]*(1-k) = 14*0.5 + 12.0*0.5 = 13.0
|
||||
result = EMA(SMALL5, timeperiod=3)
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], 11.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[3], 12.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[4], 13.0, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = EMA(SMALL5, timeperiod=3)
|
||||
assert np.all(np.isnan(result[:2]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(EMA(_CLOSE, 20)) == N
|
||||
|
||||
def test_monotone_on_rising(self):
|
||||
rising = np.arange(1.0, 51.0)
|
||||
result = EMA(rising, 5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.diff(valid) > 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWMA:
|
||||
def test_known_values(self):
|
||||
arr = np.arange(1.0, 6.0)
|
||||
result = WMA(arr, timeperiod=3)
|
||||
# weights 1,2,3 / 6
|
||||
expected_2 = (1*1 + 2*2 + 3*3) / 6.0 # 14/6
|
||||
expected_3 = (1*2 + 2*3 + 3*4) / 6.0 # 20/6
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], expected_2, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[3], expected_3, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = WMA(_CLOSE, timeperiod=10)
|
||||
assert np.all(np.isnan(result[:9]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(WMA(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDEMA:
|
||||
def test_nan_warmup(self):
|
||||
result = DEMA(_CLOSE, timeperiod=5)
|
||||
assert np.all(np.isnan(result[:8])) # DEMA needs 2*(tp-1) bars
|
||||
|
||||
def test_length(self):
|
||||
assert len(DEMA(_CLOSE, 5)) == N
|
||||
|
||||
def test_values_finite_after_warmup(self):
|
||||
result = DEMA(_CLOSE, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert len(valid) > 0
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_tracks_close(self):
|
||||
# DEMA is more responsive than EMA; on trending data it should lead EMA
|
||||
rising = np.linspace(10.0, 100.0, 100)
|
||||
dema = DEMA(rising, 5)
|
||||
ema = EMA(rising, 5)
|
||||
valid = ~np.isnan(dema) & ~np.isnan(ema)
|
||||
# DEMA > EMA on a rising series (lower lag)
|
||||
assert np.all(dema[valid] >= ema[valid] - 1e-9)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTEMA:
|
||||
def test_nan_warmup(self):
|
||||
result = TEMA(_CLOSE, timeperiod=5)
|
||||
assert np.all(np.isnan(result[:12]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(TEMA(_CLOSE, 5)) == N
|
||||
|
||||
def test_values_finite_after_warmup(self):
|
||||
result = TEMA(_CLOSE, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert len(valid) > 0
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TRIMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTRIMA:
|
||||
def test_known_values(self):
|
||||
arr = np.arange(1.0, 11.0)
|
||||
result = TRIMA(arr, timeperiod=5)
|
||||
# TRIMA(5) is SMA of SMA(3) on a 5-window
|
||||
assert np.all(np.isnan(result[:4]))
|
||||
np.testing.assert_allclose(result[4], 3.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[5], 4.0, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = TRIMA(_CLOSE, timeperiod=10)
|
||||
assert np.all(np.isnan(result[:9]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(TRIMA(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KAMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKAMA:
|
||||
def test_nan_warmup(self):
|
||||
result = KAMA(_CLOSE, timeperiod=10)
|
||||
assert np.all(np.isnan(result[:9]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(KAMA(_CLOSE, 10)) == N
|
||||
|
||||
def test_seed_equals_close(self):
|
||||
arr = np.arange(1.0, 21.0)
|
||||
result = KAMA(arr, timeperiod=10)
|
||||
# First valid KAMA value equals close at warmup index
|
||||
np.testing.assert_allclose(result[9], arr[9], rtol=1e-10)
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = KAMA(_CLOSE, timeperiod=10)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestT3:
|
||||
def test_nan_warmup(self):
|
||||
arr = np.linspace(10.0, 30.0, 100)
|
||||
result = T3(arr, timeperiod=5)
|
||||
# warmup for T3(tp) = 6*(tp-1)
|
||||
assert np.all(np.isnan(result[:24]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(T3(_CLOSE, timeperiod=5)) == N
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
arr = np.linspace(10.0, 30.0, 100)
|
||||
result = T3(arr, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert len(valid) > 0
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_trending(self):
|
||||
rising = np.linspace(10.0, 200.0, 150)
|
||||
result = T3(rising, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.diff(valid) > 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMA:
|
||||
def test_default_is_sma(self):
|
||||
result_ma = MA(_CLOSE, timeperiod=10, matype=0)
|
||||
result_sma = SMA(_CLOSE, timeperiod=10)
|
||||
np.testing.assert_allclose(result_ma, result_sma, rtol=1e-10, equal_nan=True)
|
||||
|
||||
def test_ema_matype(self):
|
||||
result_ma = MA(_CLOSE, timeperiod=10, matype=1)
|
||||
result_ema = EMA(_CLOSE, timeperiod=10)
|
||||
np.testing.assert_allclose(result_ma, result_ema, rtol=1e-10, equal_nan=True)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MA(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MACD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMACD:
|
||||
def test_returns_three_arrays(self):
|
||||
result = MACD(_CLOSE, 12, 26, 9)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_length(self):
|
||||
macd, signal, hist = MACD(_CLOSE, 12, 26, 9)
|
||||
assert len(macd) == len(signal) == len(hist) == N
|
||||
|
||||
def test_histogram_is_diff(self):
|
||||
macd, signal, hist = MACD(_CLOSE)
|
||||
valid = ~np.isnan(macd) & ~np.isnan(signal)
|
||||
np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
macd, signal, hist = MACD(_CLOSE, 12, 26, 9)
|
||||
# MACD line: warmup = slowperiod - 1 = 25
|
||||
assert np.all(np.isnan(macd[:25]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MACDFIX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMACDFIX:
|
||||
def test_returns_three_arrays(self):
|
||||
result = MACDFIX(_CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_histogram_is_diff(self):
|
||||
macd, signal, hist = MACDFIX(_CLOSE)
|
||||
valid = ~np.isnan(macd) & ~np.isnan(signal)
|
||||
np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
macd, signal, hist = MACDFIX(_CLOSE)
|
||||
assert len(macd) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MACDEXT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMACDEXT:
|
||||
def test_returns_three_arrays(self):
|
||||
result = MACDEXT(_CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_histogram_is_diff(self):
|
||||
macd, signal, hist = MACDEXT(_CLOSE)
|
||||
valid = ~np.isnan(macd) & ~np.isnan(signal)
|
||||
np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MACDEXT(_CLOSE)[0]) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBANDS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBBANDS:
|
||||
def test_returns_three_arrays(self):
|
||||
result = BBANDS(_CLOSE, 20)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_middle_is_sma(self):
|
||||
upper, middle, lower = BBANDS(_CLOSE, timeperiod=20)
|
||||
sma = SMA(_CLOSE, timeperiod=20)
|
||||
np.testing.assert_allclose(middle, sma, rtol=1e-10, equal_nan=True)
|
||||
|
||||
def test_bands_symmetric(self):
|
||||
upper, middle, lower = BBANDS(_CLOSE, 20, nbdevup=2.0, nbdevdn=2.0)
|
||||
valid = ~np.isnan(upper)
|
||||
np.testing.assert_allclose(
|
||||
upper[valid] - middle[valid],
|
||||
middle[valid] - lower[valid],
|
||||
rtol=1e-10,
|
||||
)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
upper, middle, lower = BBANDS(_CLOSE, 20)
|
||||
assert np.all(np.isnan(middle[:19]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSAR:
|
||||
def test_length(self):
|
||||
result = SAR(_HIGH, _LOW)
|
||||
assert len(result) == N
|
||||
|
||||
def test_first_is_nan(self):
|
||||
result = SAR(_HIGH, _LOW)
|
||||
assert np.isnan(result[0])
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = SAR(_HIGH, _LOW)
|
||||
assert np.all(np.isfinite(result[1:]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAREXT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSAREXT:
|
||||
def test_length(self):
|
||||
result = SAREXT(_HIGH, _LOW)
|
||||
assert len(result) == N
|
||||
|
||||
def test_first_is_nan(self):
|
||||
result = SAREXT(_HIGH, _LOW)
|
||||
assert np.isnan(result[0])
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = SAREXT(_HIGH, _LOW)
|
||||
assert np.all(np.isfinite(result[1:]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMAMA:
|
||||
def test_returns_two_arrays(self):
|
||||
result = MAMA(_CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_length(self):
|
||||
mama, fama = MAMA(_CLOSE)
|
||||
assert len(mama) == len(fama) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
mama, fama = MAMA(_CLOSE)
|
||||
assert np.all(np.isnan(mama[:32]))
|
||||
|
||||
def test_mama_ge_fama(self):
|
||||
# MAMA is adaptive; on average MAMA >= FAMA on a trending up series
|
||||
rising = np.linspace(10.0, 200.0, 200)
|
||||
mama, fama = MAMA(rising)
|
||||
valid = ~np.isnan(mama) & ~np.isnan(fama)
|
||||
# not strictly guaranteed, just check output is finite
|
||||
assert np.all(np.isfinite(mama[valid]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAVP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMAVP:
|
||||
def test_length(self):
|
||||
arr = np.linspace(10.0, 30.0, 50)
|
||||
periods = np.full(50, 5.0)
|
||||
result = MAVP(arr, periods, minperiod=2, maxperiod=10)
|
||||
assert len(result) == 50
|
||||
|
||||
def test_finite_for_large_enough_data(self):
|
||||
arr = np.linspace(10.0, 30.0, 50)
|
||||
periods = np.full(50, 3.0)
|
||||
result = MAVP(arr, periods, minperiod=2, maxperiod=10)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIDPOINT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMIDPOINT:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 12.0, 14.0, 16.0, 18.0])
|
||||
result = MIDPOINT(arr, timeperiod=3)
|
||||
# MIDPOINT(n) = (max + min) / 2 over window
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], (10.0 + 14.0) / 2.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[4], (14.0 + 18.0) / 2.0, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = MIDPOINT(_CLOSE, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:13]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(MIDPOINT(_CLOSE, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIDPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMIDPRICE:
|
||||
def test_known_values(self):
|
||||
result = MIDPRICE(SMALL5_HIGH, SMALL5_LOW, timeperiod=3)
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
# window [0..2]: max_high=13, min_low=9 → (13+9)/2 = 11
|
||||
np.testing.assert_allclose(result[2], 11.0, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = MIDPRICE(_HIGH, _LOW, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:13]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(MIDPRICE(_HIGH, _LOW, 14)) == N
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Unit tests for ferro_ta.indicators.pattern (CDL* functions)"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.pattern import (
|
||||
CDL2CROWS, CDL3BLACKCROWS, CDL3INSIDE, CDL3LINESTRIKE, CDL3OUTSIDE,
|
||||
CDL3STARSINSOUTH, CDL3WHITESOLDIERS, CDLABANDONEDBABY, CDLADVANCEBLOCK,
|
||||
CDLBELTHOLD, CDLBREAKAWAY, CDLCLOSINGMARUBOZU, CDLCONCEALBABYSWALL,
|
||||
CDLCOUNTERATTACK, CDLDARKCLOUDCOVER, CDLDOJI, CDLDOJISTAR, CDLDRAGONFLYDOJI,
|
||||
CDLENGULFING, CDLEVENINGDOJISTAR, CDLEVENINGSTAR, CDLGAPSIDESIDEWHITE,
|
||||
CDLGRAVESTONEDOJI, CDLHAMMER, CDLHANGINGMAN, CDLHARAMI, CDLHARAMICROSS,
|
||||
CDLHIGHWAVE, CDLHIKKAKE, CDLHIKKAKEMOD, CDLHOMINGPIGEON, CDLIDENTICAL3CROWS,
|
||||
CDLINNECK, CDLINVERTEDHAMMER, CDLKICKING, CDLKICKINGBYLENGTH, CDLLADDERBOTTOM,
|
||||
CDLLONGLEGGEDDOJI, CDLLONGLINE, CDLMARUBOZU, CDLMATCHINGLOW, CDLMATHOLD,
|
||||
CDLMORNINGDOJISTAR, CDLMORNINGSTAR, CDLONNECK, CDLPIERCING, CDLRICKSHAWMAN,
|
||||
CDLRISEFALL3METHODS, CDLSEPARATINGLINES, CDLSHOOTINGSTAR, CDLSHORTLINE,
|
||||
CDLSPINNINGTOP, CDLSTALLEDPATTERN, CDLSTICKSANDWICH, CDLTAKURI, CDLTASUKIGAP,
|
||||
CDLTHRUSTING, CDLTRISTAR, CDLUNIQUE3RIVER, CDLUPSIDEGAP2CROWS, CDLXSIDEGAP3METHODS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared random OHLCV data (realistic OHLCV, proper H >= O,C >= L)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(42)
|
||||
N = 200
|
||||
_C = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
||||
_O = _C + RNG.normal(0, 0.2, N)
|
||||
_H = np.maximum(np.maximum(_O, _C) + np.abs(RNG.normal(0, 0.3, N)), np.maximum(_O, _C))
|
||||
_L = np.minimum(np.minimum(_O, _C) - np.abs(RNG.normal(0, 0.3, N)), np.minimum(_O, _C))
|
||||
|
||||
# All CDL* functions to test systematically
|
||||
ALL_CDL = [
|
||||
("CDL2CROWS", CDL2CROWS),
|
||||
("CDL3BLACKCROWS", CDL3BLACKCROWS),
|
||||
("CDL3INSIDE", CDL3INSIDE),
|
||||
("CDL3LINESTRIKE", CDL3LINESTRIKE),
|
||||
("CDL3OUTSIDE", CDL3OUTSIDE),
|
||||
("CDL3STARSINSOUTH", CDL3STARSINSOUTH),
|
||||
("CDL3WHITESOLDIERS", CDL3WHITESOLDIERS),
|
||||
("CDLABANDONEDBABY", CDLABANDONEDBABY),
|
||||
("CDLADVANCEBLOCK", CDLADVANCEBLOCK),
|
||||
("CDLBELTHOLD", CDLBELTHOLD),
|
||||
("CDLBREAKAWAY", CDLBREAKAWAY),
|
||||
("CDLCLOSINGMARUBOZU", CDLCLOSINGMARUBOZU),
|
||||
("CDLCONCEALBABYSWALL", CDLCONCEALBABYSWALL),
|
||||
("CDLCOUNTERATTACK", CDLCOUNTERATTACK),
|
||||
("CDLDARKCLOUDCOVER", CDLDARKCLOUDCOVER),
|
||||
("CDLDOJI", CDLDOJI),
|
||||
("CDLDOJISTAR", CDLDOJISTAR),
|
||||
("CDLDRAGONFLYDOJI", CDLDRAGONFLYDOJI),
|
||||
("CDLENGULFING", CDLENGULFING),
|
||||
("CDLEVENINGDOJISTAR", CDLEVENINGDOJISTAR),
|
||||
("CDLEVENINGSTAR", CDLEVENINGSTAR),
|
||||
("CDLGAPSIDESIDEWHITE", CDLGAPSIDESIDEWHITE),
|
||||
("CDLGRAVESTONEDOJI", CDLGRAVESTONEDOJI),
|
||||
("CDLHAMMER", CDLHAMMER),
|
||||
("CDLHANGINGMAN", CDLHANGINGMAN),
|
||||
("CDLHARAMI", CDLHARAMI),
|
||||
("CDLHARAMICROSS", CDLHARAMICROSS),
|
||||
("CDLHIGHWAVE", CDLHIGHWAVE),
|
||||
("CDLHIKKAKE", CDLHIKKAKE),
|
||||
("CDLHIKKAKEMOD", CDLHIKKAKEMOD),
|
||||
("CDLHOMINGPIGEON", CDLHOMINGPIGEON),
|
||||
("CDLIDENTICAL3CROWS", CDLIDENTICAL3CROWS),
|
||||
("CDLINNECK", CDLINNECK),
|
||||
("CDLINVERTEDHAMMER", CDLINVERTEDHAMMER),
|
||||
("CDLKICKING", CDLKICKING),
|
||||
("CDLKICKINGBYLENGTH", CDLKICKINGBYLENGTH),
|
||||
("CDLLADDERBOTTOM", CDLLADDERBOTTOM),
|
||||
("CDLLONGLEGGEDDOJI", CDLLONGLEGGEDDOJI),
|
||||
("CDLLONGLINE", CDLLONGLINE),
|
||||
("CDLMARUBOZU", CDLMARUBOZU),
|
||||
("CDLMATCHINGLOW", CDLMATCHINGLOW),
|
||||
("CDLMATHOLD", CDLMATHOLD),
|
||||
("CDLMORNINGDOJISTAR", CDLMORNINGDOJISTAR),
|
||||
("CDLMORNINGSTAR", CDLMORNINGSTAR),
|
||||
("CDLONNECK", CDLONNECK),
|
||||
("CDLPIERCING", CDLPIERCING),
|
||||
("CDLRICKSHAWMAN", CDLRICKSHAWMAN),
|
||||
("CDLRISEFALL3METHODS", CDLRISEFALL3METHODS),
|
||||
("CDLSEPARATINGLINES", CDLSEPARATINGLINES),
|
||||
("CDLSHOOTINGSTAR", CDLSHOOTINGSTAR),
|
||||
("CDLSHORTLINE", CDLSHORTLINE),
|
||||
("CDLSPINNINGTOP", CDLSPINNINGTOP),
|
||||
("CDLSTALLEDPATTERN", CDLSTALLEDPATTERN),
|
||||
("CDLSTICKSANDWICH", CDLSTICKSANDWICH),
|
||||
("CDLTAKURI", CDLTAKURI),
|
||||
("CDLTASUKIGAP", CDLTASUKIGAP),
|
||||
("CDLTHRUSTING", CDLTHRUSTING),
|
||||
("CDLTRISTAR", CDLTRISTAR),
|
||||
("CDLUNIQUE3RIVER", CDLUNIQUE3RIVER),
|
||||
("CDLUPSIDEGAP2CROWS", CDLUPSIDEGAP2CROWS),
|
||||
("CDLXSIDEGAP3METHODS", CDLXSIDEGAP3METHODS),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parametrised tests: all CDL patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("name,fn", ALL_CDL)
|
||||
def test_cdl_output_length(name, fn):
|
||||
result = fn(_O, _H, _L, _C)
|
||||
assert len(result) == N, f"{name}: expected length {N}, got {len(result)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fn", ALL_CDL)
|
||||
def test_cdl_values_in_valid_set(name, fn):
|
||||
result = fn(_O, _H, _L, _C)
|
||||
assert np.all(np.isin(result, [-100, 0, 100])), \
|
||||
f"{name}: unexpected values {np.unique(result)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,fn", ALL_CDL)
|
||||
def test_cdl_no_nan(name, fn):
|
||||
result = fn(_O, _H, _L, _C)
|
||||
assert np.all(np.isfinite(result.astype(float))), f"{name}: contains NaN/Inf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Specific tests for previously untested patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCDLSPINNINGTOP:
|
||||
def test_detects_pattern(self):
|
||||
# Spinning top: small body, long upper and lower shadows
|
||||
# open ≈ close (small body), high much higher, low much lower
|
||||
o = np.array([10.0, 10.1, 10.0])
|
||||
h = np.array([15.0, 15.1, 15.0])
|
||||
l = np.array([5.0, 5.1, 5.0])
|
||||
c = np.array([10.0, 10.0, 10.05])
|
||||
result = CDLSPINNINGTOP(o, h, l, c)
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
def test_output_values_random(self):
|
||||
result = CDLSPINNINGTOP(_O, _H, _L, _C)
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
|
||||
class TestCDLEVENINGSTAR:
|
||||
def test_basic_run(self):
|
||||
result = CDLEVENINGSTAR(_O, _H, _L, _C)
|
||||
assert len(result) == N
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
def test_large_dataset_has_valid_output(self):
|
||||
# On 200 bars of random data, result should be all in {-100,0,100}
|
||||
result = CDLEVENINGSTAR(_O, _H, _L, _C)
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
|
||||
class TestCDLMORNINGSTAR:
|
||||
def test_basic_run(self):
|
||||
result = CDLMORNINGSTAR(_O, _H, _L, _C)
|
||||
assert len(result) == N
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
def test_bullish_signal_is_100(self):
|
||||
# Any detected signal must be 100 (bullish)
|
||||
result = CDLMORNINGSTAR(_O, _H, _L, _C)
|
||||
assert np.all(result[result != 0] == 100)
|
||||
|
||||
|
||||
class TestCDL2CROWS:
|
||||
def test_basic_run(self):
|
||||
result = CDL2CROWS(_O, _H, _L, _C)
|
||||
assert len(result) == N
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
def test_bearish_signal_is_minus_100(self):
|
||||
# Any detected signal must be -100 (bearish)
|
||||
result = CDL2CROWS(_O, _H, _L, _C)
|
||||
assert np.all(result[result != 0] == -100)
|
||||
|
||||
|
||||
class TestCDLDOJI:
|
||||
def test_detects_doji(self):
|
||||
# Exact doji: open == close
|
||||
o = np.array([10.0, 10.0, 10.0])
|
||||
h = np.array([12.0, 12.0, 12.0])
|
||||
l = np.array([8.0, 8.0, 8.0])
|
||||
c = np.array([10.0, 10.0, 10.0])
|
||||
result = CDLDOJI(o, h, l, c)
|
||||
assert np.all(result == 100)
|
||||
|
||||
def test_non_doji_returns_zero(self):
|
||||
o = np.array([10.0, 11.0, 12.0])
|
||||
h = np.array([15.0, 16.0, 17.0])
|
||||
l = np.array([9.0, 10.0, 11.0])
|
||||
c = np.array([14.0, 15.0, 16.0]) # large body, not doji
|
||||
result = CDLDOJI(o, h, l, c)
|
||||
assert np.all(result == 0)
|
||||
|
||||
|
||||
class TestCDLMARUBOZU:
|
||||
def test_detects_bullish_marubozu(self):
|
||||
# Bullish marubozu: open == low, close == high, close > open
|
||||
o = np.array([10.0, 10.0])
|
||||
h = np.array([15.0, 15.0])
|
||||
l = np.array([10.0, 10.0])
|
||||
c = np.array([15.0, 15.0])
|
||||
result = CDLMARUBOZU(o, h, l, c)
|
||||
assert np.all(np.isin(result, [-100, 0, 100]))
|
||||
|
||||
def test_length(self):
|
||||
result = CDLMARUBOZU(_O, _H, _L, _C)
|
||||
assert len(result) == N
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Unit tests for ferro_ta.indicators.price_transform"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.price_transform import AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
O = np.array([10.0, 11.0, 12.0, 13.0])
|
||||
H = np.array([12.0, 13.0, 14.0, 15.0])
|
||||
L = np.array([9.0, 10.0, 11.0, 12.0])
|
||||
C = np.array([11.0, 12.0, 13.0, 14.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AVGPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAVGPRICE:
|
||||
def test_known_formula(self):
|
||||
result = AVGPRICE(O, H, L, C)
|
||||
expected = (O + H + L + C) / 4.0
|
||||
np.testing.assert_allclose(result, expected, rtol=1e-10)
|
||||
|
||||
def test_first_bar(self):
|
||||
result = AVGPRICE(O, H, L, C)
|
||||
np.testing.assert_allclose(result[0], (10 + 12 + 9 + 11) / 4.0, rtol=1e-10)
|
||||
|
||||
def test_no_nan(self):
|
||||
result = AVGPRICE(O, H, L, C)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_length(self):
|
||||
assert len(AVGPRICE(O, H, L, C)) == len(O)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMEDPRICE:
|
||||
def test_known_formula(self):
|
||||
result = MEDPRICE(H, L)
|
||||
expected = (H + L) / 2.0
|
||||
np.testing.assert_allclose(result, expected, rtol=1e-10)
|
||||
|
||||
def test_first_bar(self):
|
||||
result = MEDPRICE(H, L)
|
||||
np.testing.assert_allclose(result[0], (12 + 9) / 2.0, rtol=1e-10)
|
||||
|
||||
def test_no_nan(self):
|
||||
result = MEDPRICE(H, L)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_length(self):
|
||||
assert len(MEDPRICE(H, L)) == len(H)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TYPPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTYPPRICE:
|
||||
def test_known_formula(self):
|
||||
result = TYPPRICE(H, L, C)
|
||||
expected = (H + L + C) / 3.0
|
||||
np.testing.assert_allclose(result, expected, rtol=1e-10)
|
||||
|
||||
def test_first_bar(self):
|
||||
result = TYPPRICE(H, L, C)
|
||||
np.testing.assert_allclose(result[0], (12 + 9 + 11) / 3.0, rtol=1e-10)
|
||||
|
||||
def test_no_nan(self):
|
||||
result = TYPPRICE(H, L, C)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_length(self):
|
||||
assert len(TYPPRICE(H, L, C)) == len(H)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WCLPRICE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWCLPRICE:
|
||||
def test_known_formula(self):
|
||||
result = WCLPRICE(H, L, C)
|
||||
expected = (H + L + 2.0 * C) / 4.0
|
||||
np.testing.assert_allclose(result, expected, rtol=1e-10)
|
||||
|
||||
def test_first_bar(self):
|
||||
result = WCLPRICE(H, L, C)
|
||||
np.testing.assert_allclose(result[0], (12 + 9 + 2 * 11) / 4.0, rtol=1e-10)
|
||||
|
||||
def test_no_nan(self):
|
||||
result = WCLPRICE(H, L, C)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_close_weight_double(self):
|
||||
# WCLPRICE weights close twice vs TYPPRICE
|
||||
typ = TYPPRICE(H, L, C)
|
||||
wcl = WCLPRICE(H, L, C)
|
||||
# On a rising series (H > L > 0), WCLPRICE > TYPPRICE when C > (H+L)/2
|
||||
# Just verify formula correctness already done above
|
||||
assert np.all(np.isfinite(wcl))
|
||||
|
||||
def test_length(self):
|
||||
assert len(WCLPRICE(H, L, C)) == len(H)
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Unit tests for ferro_ta.indicators.statistic"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.statistic import (
|
||||
STDDEV, VAR, BETA, CORREL,
|
||||
LINEARREG, LINEARREG_ANGLE, LINEARREG_INTERCEPT, LINEARREG_SLOPE,
|
||||
TSF,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Unit tests for ferro_ta.indicators.volatility"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.volatility import ATR, NATR, TRANGE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(3)
|
||||
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))
|
||||
|
||||
# Simple 5-bar data with constant range
|
||||
SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TRANGE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTRANGE:
|
||||
def test_known_values_constant_range(self):
|
||||
result = TRANGE(SMALL_H, SMALL_L, SMALL_C)
|
||||
# First bar: only high-low = 3 (no prior close)
|
||||
np.testing.assert_allclose(result[0], 3.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[1], 3.0, rtol=1e-10)
|
||||
|
||||
def test_no_nan(self):
|
||||
result = TRANGE(SMALL_H, SMALL_L, SMALL_C)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_always_positive(self):
|
||||
result = TRANGE(_HIGH, _LOW, _CLOSE)
|
||||
assert np.all(result > 0)
|
||||
|
||||
def test_length(self):
|
||||
assert len(TRANGE(_HIGH, _LOW, _CLOSE)) == N
|
||||
|
||||
def test_formula_first_bar(self):
|
||||
h = np.array([15.0, 16.0, 17.0])
|
||||
l = np.array([10.0, 11.0, 12.0])
|
||||
c = np.array([13.0, 14.0, 15.0])
|
||||
result = TRANGE(h, l, c)
|
||||
# bar 0: TRANGE = h[0] - l[0] = 5
|
||||
np.testing.assert_allclose(result[0], 5.0, rtol=1e-10)
|
||||
# bar 1: max(h[1]-l[1], |h[1]-c[0]|, |l[1]-c[0]|)
|
||||
# = max(5, |16-13|, |11-13|) = max(5, 3, 2) = 5
|
||||
np.testing.assert_allclose(result[1], 5.0, rtol=1e-10)
|
||||
|
||||
def test_with_gap(self):
|
||||
# Gap up: prev close=10, curr high=20, curr low=15
|
||||
h = np.array([10.0, 20.0])
|
||||
l = np.array([8.0, 15.0])
|
||||
c = np.array([10.0, 18.0])
|
||||
result = TRANGE(h, l, c)
|
||||
# bar 1: max(20-15, |20-10|, |15-10|) = max(5, 10, 5) = 10
|
||||
np.testing.assert_allclose(result[1], 10.0, rtol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ATR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestATR:
|
||||
def test_timeperiod_1_equals_trange(self):
|
||||
atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1)
|
||||
trange = TRANGE(SMALL_H, SMALL_L, SMALL_C)
|
||||
# ATR(1) first bar is NaN, subsequent equal TRANGE
|
||||
np.testing.assert_allclose(atr[1:], trange[1:], rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = ATR(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:14]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(ATR(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
def test_always_positive(self):
|
||||
result = ATR(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid > 0)
|
||||
|
||||
def test_constant_range_converges(self):
|
||||
# Constant TRANGE=3 → ATR should converge to 3
|
||||
h = np.full(100, 12.0) + np.arange(100) * 0.0
|
||||
l = np.full(100, 9.0) + np.arange(100) * 0.0
|
||||
c = np.full(100, 11.0) + np.arange(100) * 0.0
|
||||
result = ATR(h, l, c, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
np.testing.assert_allclose(valid[-1], 3.0, atol=0.01)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NATR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNATR:
|
||||
def test_nan_warmup(self):
|
||||
result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:14]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(NATR(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
def test_positive(self):
|
||||
result = NATR(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid > 0)
|
||||
|
||||
def test_relation_to_atr(self):
|
||||
# NATR = ATR / close * 100
|
||||
atr = ATR(_HIGH, _LOW, _CLOSE, 14)
|
||||
natr = NATR(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = ~np.isnan(atr) & ~np.isnan(natr)
|
||||
expected = atr[valid] / _CLOSE[valid] * 100
|
||||
np.testing.assert_allclose(natr[valid], expected, rtol=1e-5)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Unit tests for ferro_ta.indicators.volume"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta.indicators.volume import AD, ADOSC, OBV
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(5)
|
||||
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))
|
||||
_VOL = RNG.uniform(1000, 5000, N)
|
||||
|
||||
SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
SMALL_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OBV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOBV:
|
||||
def test_known_values_rising(self):
|
||||
# Rising close: OBV accumulates all volume
|
||||
c = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
v = np.array([1000.0, 1000.0, 1000.0, 1000.0, 1000.0])
|
||||
result = OBV(c, v)
|
||||
np.testing.assert_allclose(result[0], 0.0, atol=1e-10)
|
||||
np.testing.assert_allclose(result[1], 1000.0, atol=1e-10)
|
||||
np.testing.assert_allclose(result[4], 4000.0, atol=1e-10)
|
||||
|
||||
def test_known_values_falling(self):
|
||||
c = np.array([14.0, 13.0, 12.0, 11.0, 10.0])
|
||||
v = np.array([1000.0, 1000.0, 1000.0, 1000.0, 1000.0])
|
||||
result = OBV(c, v)
|
||||
np.testing.assert_allclose(result[0], 0.0, atol=1e-10)
|
||||
np.testing.assert_allclose(result[1], -1000.0, atol=1e-10)
|
||||
np.testing.assert_allclose(result[4], -4000.0, atol=1e-10)
|
||||
|
||||
def test_unchanged_price_no_change(self):
|
||||
c = np.array([10.0, 10.0, 10.0])
|
||||
v = np.array([500.0, 500.0, 500.0])
|
||||
result = OBV(c, v)
|
||||
np.testing.assert_allclose(result, [0.0, 0.0, 0.0], atol=1e-10)
|
||||
|
||||
def test_no_nan(self):
|
||||
result = OBV(SMALL_C, SMALL_V)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_length(self):
|
||||
assert len(OBV(_CLOSE, _VOL)) == N
|
||||
|
||||
def test_starts_zero(self):
|
||||
result = OBV(_CLOSE, _VOL)
|
||||
np.testing.assert_allclose(result[0], 0.0, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAD:
|
||||
def test_known_formula(self):
|
||||
# AD = cumsum(CLV * volume)
|
||||
# CLV = ((close - low) - (high - close)) / (high - low)
|
||||
h = np.array([15.0])
|
||||
l = np.array([10.0])
|
||||
c = np.array([12.0])
|
||||
v = np.array([1000.0])
|
||||
clv = ((12 - 10) - (15 - 12)) / (15 - 10) # (2 - 3) / 5 = -0.2
|
||||
expected = clv * 1000.0
|
||||
result = AD(h, l, c, v)
|
||||
np.testing.assert_allclose(result[0], expected, rtol=1e-10)
|
||||
|
||||
def test_monotone_rising_positive(self):
|
||||
# High CLV on rising data → AD should be non-negative cumulatively
|
||||
result = AD(SMALL_H, SMALL_L, SMALL_C, SMALL_V)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_no_nan(self):
|
||||
result = AD(_HIGH, _LOW, _CLOSE, _VOL)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_length(self):
|
||||
assert len(AD(_HIGH, _LOW, _CLOSE, _VOL)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADOSC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestADOSC:
|
||||
def test_nan_warmup(self):
|
||||
result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10)
|
||||
assert np.all(np.isnan(result[:9]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(ADOSC(_HIGH, _LOW, _CLOSE, _VOL, 3, 10)) == N
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_known_values(self):
|
||||
result = ADOSC(SMALL_H, SMALL_L, SMALL_C, SMALL_V, fastperiod=2, slowperiod=3)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert len(valid) > 0
|
||||
assert np.all(np.isfinite(valid))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,685 @@
|
||||
"""Tests for resampling, tick aggregation, DSL, signals,
|
||||
portfolio analytics, cross-asset analytics, feature matrix, viz, and adapters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(2024)
|
||||
|
||||
|
||||
def _make_ohlcv(n: int = 100):
|
||||
"""Return (open, high, low, close, volume) as numpy arrays."""
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, n)) * 100.0
|
||||
open_ = close * RNG.uniform(0.995, 1.005, n)
|
||||
high = np.maximum(close, open_) + RNG.uniform(0, 0.5, n)
|
||||
low = np.minimum(close, open_) - RNG.uniform(0, 0.5, n)
|
||||
volume = RNG.uniform(500, 5000, n)
|
||||
return open_, high, low, close, volume
|
||||
|
||||
|
||||
def _make_ticks(n: int = 500):
|
||||
price = 100.0 + np.cumsum(RNG.normal(0, 0.05, n))
|
||||
size = RNG.uniform(10, 100, n)
|
||||
return price, size
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resampling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVolumeBarResampling:
|
||||
"""Rust-backed volume_bars function."""
|
||||
|
||||
def test_returns_five_arrays(self):
|
||||
from ferro_ta.data.resampling import volume_bars
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(100)
|
||||
bars = volume_bars((o, h, l, c, v), volume_threshold=2000)
|
||||
assert len(bars) == 5
|
||||
assert all(isinstance(b, np.ndarray) for b in bars)
|
||||
|
||||
def test_volume_bars_reduce_length(self):
|
||||
from ferro_ta.data.resampling import volume_bars
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(200)
|
||||
bars = volume_bars((o, h, l, c, v), volume_threshold=5000)
|
||||
# Output should have fewer bars than input
|
||||
assert len(bars[0]) < 200
|
||||
|
||||
def test_each_bar_high_ge_low(self):
|
||||
from ferro_ta.data.resampling import volume_bars
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(100)
|
||||
ro, rh, rl, rc, rv = volume_bars((o, h, l, c, v), volume_threshold=2000)
|
||||
assert np.all(rh >= rl)
|
||||
|
||||
def test_output_volume_ge_threshold(self):
|
||||
from ferro_ta.data.resampling import volume_bars
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(100)
|
||||
threshold = 1500.0
|
||||
_, _, _, _, rv = volume_bars((o, h, l, c, v), volume_threshold=threshold)
|
||||
# All but the last bar should satisfy the threshold
|
||||
if len(rv) > 1:
|
||||
assert np.all(rv[:-1] >= threshold)
|
||||
|
||||
def test_invalid_threshold_raises(self):
|
||||
from ferro_ta.data.resampling import volume_bars
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(10)
|
||||
with pytest.raises(Exception):
|
||||
volume_bars((o, h, l, c, v), volume_threshold=-1)
|
||||
|
||||
def test_ohlcv_agg_rust_function(self):
|
||||
from ferro_ta._ferro_ta import ohlcv_agg
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(10)
|
||||
labels = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2, 2], dtype=np.int64)
|
||||
ro, rh, rl, rc, rv = ohlcv_agg(o, h, l, c, v, labels)
|
||||
assert len(ro) == 3
|
||||
|
||||
def test_resample_with_pandas(self):
|
||||
"""Time-based resampling using pandas DatetimeIndex."""
|
||||
pytest.importorskip("pandas")
|
||||
import pandas as pd
|
||||
|
||||
from ferro_ta.data.resampling import resample
|
||||
|
||||
idx = pd.date_range("2024-01-01", periods=60, freq="1min")
|
||||
o, h, l, c, v = _make_ohlcv(60)
|
||||
df = pd.DataFrame(
|
||||
{"open": o, "high": h, "low": l, "close": c, "volume": v},
|
||||
index=idx,
|
||||
)
|
||||
df5 = resample(df, "5min")
|
||||
# 60 1-minute bars → 12 or 13 5-minute bars depending on pandas version/label
|
||||
assert 11 <= len(df5) <= 13
|
||||
assert set(df5.columns) == {"open", "high", "low", "close", "volume"}
|
||||
|
||||
def test_volume_bars_dataframe_return(self):
|
||||
pytest.importorskip("pandas")
|
||||
import pandas as pd
|
||||
|
||||
from ferro_ta.data.resampling import volume_bars
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(60)
|
||||
df = pd.DataFrame({"open": o, "high": h, "low": l, "close": c, "volume": v})
|
||||
result = volume_bars(df, volume_threshold=3000)
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert "close" in result.columns
|
||||
|
||||
def test_multi_timeframe_returns_dict(self):
|
||||
pytest.importorskip("pandas")
|
||||
import pandas as pd
|
||||
|
||||
from ferro_ta import RSI
|
||||
from ferro_ta.data.resampling import multi_timeframe
|
||||
|
||||
idx = pd.date_range("2024-01-01", periods=200, freq="1min")
|
||||
o, h, l, c, v = _make_ohlcv(200)
|
||||
df = pd.DataFrame(
|
||||
{"open": o, "high": h, "low": l, "close": c, "volume": v},
|
||||
index=idx,
|
||||
)
|
||||
result = multi_timeframe(
|
||||
df, ["5min", "15min"], indicator=RSI, indicator_kwargs={"timeperiod": 14}
|
||||
)
|
||||
assert sorted(result.keys()) == ["15min", "5min"]
|
||||
for key, arr in result.items():
|
||||
assert isinstance(arr, np.ndarray)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTickAggregation:
|
||||
"""aggregate_ticks and TickAggregator."""
|
||||
|
||||
def test_tick_bars_dict_input(self):
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
price, size = _make_ticks(500)
|
||||
result = aggregate_ticks({"price": price, "size": size}, rule="tick:50")
|
||||
assert "open" in result
|
||||
# 500 / 50 = 10 bars
|
||||
assert len(result["open"]) == 10
|
||||
|
||||
def test_volume_bars_ticks(self):
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
price, size = _make_ticks(200)
|
||||
result = aggregate_ticks({"price": price, "size": size}, rule="volume:500")
|
||||
assert len(result["open"]) > 0
|
||||
|
||||
def test_time_bars_ticks(self):
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
price, size = _make_ticks(300)
|
||||
ts = np.arange(300, dtype=np.float64) # 1 second intervals
|
||||
result = aggregate_ticks(
|
||||
{"timestamp": ts, "price": price, "size": size}, rule="time:60"
|
||||
)
|
||||
# 300 seconds / 60 = 5 bars
|
||||
assert len(result["open"]) == 5
|
||||
|
||||
def test_tick_aggregator_class(self):
|
||||
from ferro_ta.data.aggregation import TickAggregator
|
||||
|
||||
agg = TickAggregator(rule="tick:50")
|
||||
price, size = _make_ticks(200)
|
||||
result = agg.aggregate({"price": price, "size": size})
|
||||
assert len(result["open"]) == 4 # 200 / 50 = 4
|
||||
|
||||
def test_invalid_rule_raises(self):
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
price, size = _make_ticks(100)
|
||||
with pytest.raises(ValueError, match="Invalid rule"):
|
||||
aggregate_ticks({"price": price, "size": size}, rule="bad_rule")
|
||||
|
||||
def test_unknown_bar_type_raises(self):
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
price, size = _make_ticks(100)
|
||||
with pytest.raises(ValueError, match="Unknown bar type"):
|
||||
aggregate_ticks({"price": price, "size": size}, rule="unknown:50")
|
||||
|
||||
def test_tick_bars_indicator_pipeline(self):
|
||||
"""Full pipeline: ticks → bars → RSI."""
|
||||
from ferro_ta import RSI
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
price, size = _make_ticks(1000)
|
||||
bars = aggregate_ticks({"price": price, "size": size}, rule="tick:20")
|
||||
close = np.asarray(bars["close"], dtype=np.float64)
|
||||
rsi = RSI(close, timeperiod=14)
|
||||
assert rsi.shape == close.shape
|
||||
|
||||
def test_list_input(self):
|
||||
from ferro_ta.data.aggregation import aggregate_ticks
|
||||
|
||||
ticks = [(float(i), 100.0 + i * 0.01, 10.0) for i in range(100)]
|
||||
result = aggregate_ticks(ticks, rule="tick:10")
|
||||
assert len(result["open"]) == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategy DSL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStrategyDSL:
|
||||
def test_parse_simple_expression(self):
|
||||
from ferro_ta.tools.dsl import parse_expression
|
||||
|
||||
ast = parse_expression("RSI(14) < 30")
|
||||
assert ast is not None
|
||||
|
||||
def test_evaluate_returns_int_array(self):
|
||||
from ferro_ta.tools.dsl import evaluate
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100
|
||||
sig = evaluate("RSI(14) < 30", {"close": close})
|
||||
assert sig.dtype == np.int32
|
||||
assert sig.shape == (100,)
|
||||
assert set(sig.tolist()).issubset({0, 1})
|
||||
|
||||
def test_evaluate_and_expression(self):
|
||||
from ferro_ta.tools.dsl import evaluate
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100
|
||||
sig = evaluate("RSI(14) < 70 and RSI(14) > 30", {"close": close})
|
||||
assert sig.shape == (100,)
|
||||
|
||||
def test_evaluate_or_expression(self):
|
||||
from ferro_ta.tools.dsl import evaluate
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100
|
||||
sig = evaluate("RSI(14) < 30 or RSI(14) > 70", {"close": close})
|
||||
assert sig.shape == (100,)
|
||||
|
||||
def test_evaluate_not_expression(self):
|
||||
from ferro_ta.tools.dsl import evaluate
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100
|
||||
sig = evaluate("not RSI(14) < 30", {"close": close})
|
||||
assert set(sig.tolist()).issubset({0, 1})
|
||||
|
||||
def test_strategy_class(self):
|
||||
from ferro_ta.tools.dsl import Strategy
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100
|
||||
strat = Strategy("RSI(14) < 30")
|
||||
sig = strat.evaluate({"close": close})
|
||||
assert sig.shape == (100,)
|
||||
|
||||
def test_combined_close_sma_expression(self):
|
||||
from ferro_ta.tools.dsl import evaluate
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 60)) * 100
|
||||
sig = evaluate("close > SMA(20)", {"close": close})
|
||||
assert sig.shape == (60,)
|
||||
|
||||
def test_invalid_expression_raises(self):
|
||||
from ferro_ta.tools.dsl import parse_expression
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
parse_expression("")
|
||||
|
||||
def test_parse_expression_with_cross_above_placeholder(self):
|
||||
"""cross_above tokens parse without error."""
|
||||
from ferro_ta.tools.dsl import parse_expression
|
||||
|
||||
ast = parse_expression("cross_above(close, SMA(20))")
|
||||
assert ast is not None
|
||||
|
||||
def test_backtest_with_dsl_signal(self):
|
||||
"""Combine DSL signal with the existing backtest module."""
|
||||
from ferro_ta.analysis.backtest import backtest
|
||||
from ferro_ta.tools.dsl import Strategy
|
||||
|
||||
close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100
|
||||
strat = Strategy("RSI(14) < 30")
|
||||
strat.evaluate({"close": close}) # signal not fed to backtest in this test
|
||||
# Manually feed signal to backtest
|
||||
result = backtest(close, strategy="rsi_30_70")
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal composition and screening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSignalComposition:
|
||||
def test_compose_weighted(self):
|
||||
from ferro_ta.analysis.signals import compose
|
||||
|
||||
sigs = RNG.standard_normal((50, 3))
|
||||
score = compose(sigs, weights=[0.5, 0.3, 0.2])
|
||||
assert score.shape == (50,)
|
||||
|
||||
def test_compose_mean(self):
|
||||
from ferro_ta.analysis.signals import compose
|
||||
|
||||
sigs = np.ones((10, 4)) * 2.0
|
||||
score = compose(sigs, method="mean")
|
||||
np.testing.assert_allclose(score, 2.0)
|
||||
|
||||
def test_compose_rank(self):
|
||||
from ferro_ta.analysis.signals import compose
|
||||
|
||||
sigs = RNG.standard_normal((30, 3))
|
||||
score = compose(sigs, method="rank")
|
||||
assert score.shape == (30,)
|
||||
|
||||
def test_compose_equal_weights_default(self):
|
||||
from ferro_ta.analysis.signals import compose
|
||||
|
||||
sigs = np.ones((5, 3))
|
||||
score = compose(sigs) # equal weight by default
|
||||
np.testing.assert_allclose(score, 1.0)
|
||||
|
||||
def test_screen_top_n(self):
|
||||
from ferro_ta.analysis.signals import screen
|
||||
|
||||
scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3}
|
||||
result = screen(scores, top_n=2)
|
||||
assert list(result.keys()) == ["MSFT", "AAPL"]
|
||||
|
||||
def test_screen_bottom_n(self):
|
||||
from ferro_ta.analysis.signals import screen
|
||||
|
||||
scores = {"A": 3, "B": 1, "C": 2}
|
||||
result = screen(scores, bottom_n=2)
|
||||
assert list(result.keys()) == ["B", "C"]
|
||||
|
||||
def test_screen_above_threshold(self):
|
||||
from ferro_ta.analysis.signals import screen
|
||||
|
||||
scores = {"A": 0.7, "B": 0.3, "C": 0.9}
|
||||
result = screen(scores, above=0.5)
|
||||
assert set(result.keys()) == {"A", "C"}
|
||||
|
||||
def test_rank_signals(self):
|
||||
from ferro_ta.analysis.signals import rank_signals
|
||||
|
||||
x = np.array([3.0, 1.0, 2.0])
|
||||
r = rank_signals(x)
|
||||
np.testing.assert_allclose(r, [3.0, 1.0, 2.0])
|
||||
|
||||
def test_rank_signals_ties(self):
|
||||
from ferro_ta.analysis.signals import rank_signals
|
||||
|
||||
x = np.array([1.0, 1.0, 3.0])
|
||||
r = rank_signals(x)
|
||||
np.testing.assert_allclose(r[0], 1.5)
|
||||
np.testing.assert_allclose(r[1], 1.5)
|
||||
np.testing.assert_allclose(r[2], 3.0)
|
||||
|
||||
def test_top_n_indices_rust(self):
|
||||
from ferro_ta._ferro_ta import top_n_indices
|
||||
|
||||
x = np.array([1.0, 5.0, 3.0, 7.0, 2.0])
|
||||
idx = top_n_indices(x, 2)
|
||||
vals = sorted(x[i] for i in idx)
|
||||
assert vals == [5.0, 7.0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portfolio analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPortfolioAnalytics:
|
||||
def test_correlation_matrix_shape(self):
|
||||
from ferro_ta.analysis.portfolio import correlation_matrix
|
||||
|
||||
r = RNG.normal(0, 0.01, (100, 4))
|
||||
corr = correlation_matrix(r)
|
||||
assert corr.shape == (4, 4)
|
||||
|
||||
def test_correlation_matrix_diagonal_ones(self):
|
||||
from ferro_ta.analysis.portfolio import correlation_matrix
|
||||
|
||||
r = RNG.normal(0, 0.01, (100, 3))
|
||||
corr = correlation_matrix(r)
|
||||
np.testing.assert_allclose(np.diag(corr), 1.0, atol=1e-10)
|
||||
|
||||
def test_correlation_matrix_symmetric(self):
|
||||
from ferro_ta.analysis.portfolio import correlation_matrix
|
||||
|
||||
r = RNG.normal(0, 0.01, (80, 3))
|
||||
corr = correlation_matrix(r)
|
||||
np.testing.assert_allclose(corr, corr.T, atol=1e-12)
|
||||
|
||||
def test_portfolio_volatility_positive(self):
|
||||
from ferro_ta.analysis.portfolio import portfolio_volatility
|
||||
|
||||
r = RNG.normal(0, 0.01, (100, 3))
|
||||
vol = portfolio_volatility(r, weights=[1 / 3, 1 / 3, 1 / 3])
|
||||
assert vol > 0
|
||||
|
||||
def test_portfolio_volatility_annualise(self):
|
||||
from ferro_ta.analysis.portfolio import portfolio_volatility
|
||||
|
||||
r = RNG.normal(0, 0.01, (252, 1))
|
||||
vol_raw = portfolio_volatility(r, weights=[1.0])
|
||||
vol_ann = portfolio_volatility(r, weights=[1.0], annualise=252)
|
||||
np.testing.assert_allclose(vol_ann, vol_raw * 252**0.5, rtol=1e-6)
|
||||
|
||||
def test_beta_scalar(self):
|
||||
from ferro_ta.analysis.portfolio import beta
|
||||
|
||||
bench = RNG.normal(0, 0.01, 100)
|
||||
asset = 1.5 * bench + RNG.normal(0, 0.001, 100)
|
||||
b = beta(asset, bench)
|
||||
assert abs(b - 1.5) < 0.05
|
||||
|
||||
def test_beta_rolling(self):
|
||||
from ferro_ta.analysis.portfolio import beta
|
||||
|
||||
bench = RNG.normal(0, 0.01, 100)
|
||||
asset = bench + RNG.normal(0, 0.001, 100)
|
||||
rb = beta(asset, bench, window=20)
|
||||
assert rb.shape == (100,)
|
||||
assert np.isnan(rb[0])
|
||||
assert not np.isnan(rb[-1])
|
||||
|
||||
def test_drawdown_series(self):
|
||||
from ferro_ta.analysis.portfolio import drawdown
|
||||
|
||||
eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0])
|
||||
dd, max_dd = drawdown(eq)
|
||||
assert dd.shape == (5,)
|
||||
assert dd[0] == 0.0 # no drawdown at start
|
||||
assert max_dd < 0
|
||||
|
||||
def test_drawdown_max_only(self):
|
||||
from ferro_ta.analysis.portfolio import drawdown
|
||||
|
||||
eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0])
|
||||
max_dd = drawdown(eq, as_series=False)
|
||||
assert isinstance(max_dd, float)
|
||||
assert max_dd < 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-asset analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCrossAsset:
|
||||
def test_relative_strength_shape(self):
|
||||
from ferro_ta.analysis.cross_asset import relative_strength
|
||||
|
||||
ra = RNG.normal(0, 0.01, 50)
|
||||
rb = RNG.normal(0, 0.01, 50)
|
||||
rs = relative_strength(ra, rb)
|
||||
assert rs.shape == (50,)
|
||||
|
||||
def test_spread_values(self):
|
||||
from ferro_ta.analysis.cross_asset import spread
|
||||
|
||||
a = np.array([10.0, 11.0, 12.0])
|
||||
b = np.array([9.0, 10.0, 11.0])
|
||||
sp = spread(a, b)
|
||||
np.testing.assert_allclose(sp, [1.0, 1.0, 1.0])
|
||||
|
||||
def test_spread_custom_hedge(self):
|
||||
from ferro_ta.analysis.cross_asset import spread
|
||||
|
||||
a = np.array([10.0, 10.0])
|
||||
b = np.array([5.0, 5.0])
|
||||
sp = spread(a, b, hedge=2.0)
|
||||
np.testing.assert_allclose(sp, [0.0, 0.0])
|
||||
|
||||
def test_ratio_basic(self):
|
||||
from ferro_ta.analysis.cross_asset import ratio
|
||||
|
||||
a = np.array([10.0, 12.0, 15.0])
|
||||
b = np.array([5.0, 4.0, 5.0])
|
||||
r = ratio(a, b)
|
||||
np.testing.assert_allclose(r, [2.0, 3.0, 3.0])
|
||||
|
||||
def test_ratio_zero_denominator(self):
|
||||
from ferro_ta.analysis.cross_asset import ratio
|
||||
|
||||
a = np.array([1.0, 2.0])
|
||||
b = np.array([0.0, 1.0])
|
||||
r = ratio(a, b)
|
||||
assert np.isnan(r[0])
|
||||
assert r[1] == 2.0
|
||||
|
||||
def test_zscore_nan_warmup(self):
|
||||
from ferro_ta.analysis.cross_asset import zscore
|
||||
|
||||
x = np.array([1.0, 2.0, 3.0, 2.0, 1.0])
|
||||
z = zscore(x, window=3)
|
||||
assert np.isnan(z[0]) and np.isnan(z[1])
|
||||
assert not np.isnan(z[2])
|
||||
|
||||
def test_rolling_beta_warmup(self):
|
||||
from ferro_ta.analysis.cross_asset import rolling_beta
|
||||
|
||||
b = RNG.normal(0, 1, 50)
|
||||
a = 0.8 * b + RNG.normal(0, 0.1, 50)
|
||||
rb = rolling_beta(a, b, window=20)
|
||||
assert np.isnan(rb[18])
|
||||
assert not np.isnan(rb[19])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFeatureMatrix:
|
||||
def test_basic_feature_matrix(self):
|
||||
from ferro_ta.analysis.features import feature_matrix
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(50)
|
||||
ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v}
|
||||
fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10})])
|
||||
assert "SMA" in fm
|
||||
arr = np.asarray(fm["SMA"] if isinstance(fm, dict) else fm["SMA"].values)
|
||||
assert arr.shape == (50,)
|
||||
|
||||
def test_multiple_indicators_feature_matrix(self):
|
||||
from ferro_ta.analysis.features import feature_matrix
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(50)
|
||||
ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v}
|
||||
fm = feature_matrix(
|
||||
ohlcv,
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("RSI", {"timeperiod": 14}),
|
||||
],
|
||||
)
|
||||
assert "SMA" in fm
|
||||
assert "RSI" in fm
|
||||
|
||||
def test_nan_policy_drop(self):
|
||||
pytest.importorskip("pandas")
|
||||
import pandas as pd
|
||||
|
||||
from ferro_ta.analysis.features import feature_matrix
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(50)
|
||||
ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v}
|
||||
fm = feature_matrix(
|
||||
ohlcv,
|
||||
[("SMA", {"timeperiod": 10}), ("RSI", {"timeperiod": 14})],
|
||||
nan_policy="drop",
|
||||
)
|
||||
assert isinstance(fm, pd.DataFrame)
|
||||
assert not fm.isnull().any().any()
|
||||
|
||||
def test_feature_matrix_string_indicator(self):
|
||||
from ferro_ta.analysis.features import feature_matrix
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(50)
|
||||
ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v}
|
||||
fm = feature_matrix(ohlcv, ["SMA"])
|
||||
assert "SMA" in fm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Viz (smoke tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestViz:
|
||||
def test_plot_matplotlib_no_show(self):
|
||||
pytest.importorskip("matplotlib")
|
||||
from ferro_ta import RSI
|
||||
from ferro_ta.tools.viz import plot
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(60)
|
||||
ohlcv = {"close": c, "open": o, "high": h, "low": l, "volume": v}
|
||||
rsi = RSI(c, timeperiod=14)
|
||||
fig = plot(
|
||||
ohlcv,
|
||||
indicators={"RSI(14)": rsi},
|
||||
backend="matplotlib",
|
||||
show=False,
|
||||
)
|
||||
assert fig is not None
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
plt.close("all")
|
||||
|
||||
def test_plot_unknown_backend_raises(self):
|
||||
from ferro_ta.tools.viz import plot
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(10)
|
||||
with pytest.raises(ValueError, match="Unknown backend"):
|
||||
plot({"close": c}, backend="bogus")
|
||||
|
||||
def test_plot_savefig(self, tmp_path):
|
||||
pytest.importorskip("matplotlib")
|
||||
from ferro_ta.tools.viz import plot
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(30)
|
||||
ohlcv = {"close": c, "open": o, "high": h, "low": l, "volume": v}
|
||||
out = str(tmp_path / "chart.png")
|
||||
plot(ohlcv, backend="matplotlib", savefig=out, show=False)
|
||||
import os
|
||||
|
||||
assert os.path.exists(out)
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
plt.close("all")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataAdapters:
|
||||
def test_in_memory_adapter(self):
|
||||
from ferro_ta.data.adapters import InMemoryAdapter
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(20)
|
||||
adapter = InMemoryAdapter(
|
||||
{"open": o, "high": h, "low": l, "close": c, "volume": v}
|
||||
)
|
||||
ohlcv = adapter.fetch()
|
||||
assert "close" in ohlcv
|
||||
|
||||
def test_register_and_get_adapter(self):
|
||||
from ferro_ta.data.adapters import DataAdapter, get_adapter, register_adapter
|
||||
|
||||
class MyAdapter(DataAdapter):
|
||||
def fetch(self, **kwargs):
|
||||
return {}
|
||||
|
||||
register_adapter("_test_my", MyAdapter)
|
||||
cls = get_adapter("_test_my")
|
||||
assert cls is MyAdapter
|
||||
|
||||
def test_get_unknown_adapter_raises(self):
|
||||
from ferro_ta.data.adapters import get_adapter
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
get_adapter("_nonexistent_adapter_xyz")
|
||||
|
||||
def test_csv_adapter_requires_pandas(self, tmp_path):
|
||||
"""CsvAdapter can be instantiated without pandas; fetch raises ImportError."""
|
||||
from ferro_ta.data.adapters import CsvAdapter
|
||||
|
||||
adapter = CsvAdapter(str(tmp_path / "fake.csv"))
|
||||
assert adapter is not None
|
||||
|
||||
def test_csv_adapter_fetch(self, tmp_path):
|
||||
pytest.importorskip("pandas")
|
||||
import pandas as pd
|
||||
|
||||
from ferro_ta.data.adapters import CsvAdapter
|
||||
|
||||
o, h, l, c, v = _make_ohlcv(10)
|
||||
csv_path = str(tmp_path / "ohlcv.csv")
|
||||
df = pd.DataFrame({"open": o, "high": h, "low": l, "close": c, "volume": v})
|
||||
df.to_csv(csv_path, index=False)
|
||||
adapter = CsvAdapter(csv_path)
|
||||
result = adapter.fetch()
|
||||
assert "close" in result.columns
|
||||
assert len(result) == 10
|
||||
|
||||
def test_builtin_adapters_registered(self):
|
||||
from ferro_ta.data.adapters import CsvAdapter, InMemoryAdapter, get_adapter
|
||||
|
||||
assert get_adapter("csv") is CsvAdapter
|
||||
assert get_adapter("memory") is InMemoryAdapter
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,930 @@
|
||||
"""Tests for exceptions, backtest, registry, release playbook, GPU backend, WASM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ferro_ta
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception model & validation
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.core.exceptions import (
|
||||
FerroTAError,
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
check_equal_length,
|
||||
check_finite,
|
||||
check_timeperiod,
|
||||
)
|
||||
|
||||
|
||||
class TestExceptionHierarchy:
|
||||
"""FerroTAError hierarchy and isinstance relationships."""
|
||||
|
||||
def test_ferro_ta_error_is_exception(self):
|
||||
assert issubclass(FerroTAError, Exception)
|
||||
|
||||
def test_value_error_is_base_and_value_error(self):
|
||||
assert issubclass(FerroTAValueError, FerroTAError)
|
||||
assert issubclass(FerroTAValueError, ValueError)
|
||||
|
||||
def test_input_error_is_base_and_value_error(self):
|
||||
assert issubclass(FerroTAInputError, FerroTAError)
|
||||
assert issubclass(FerroTAInputError, ValueError)
|
||||
|
||||
def test_exported_from_ferro_ta(self):
|
||||
assert ferro_ta.FerroTAError is FerroTAError
|
||||
assert ferro_ta.FerroTAValueError is FerroTAValueError
|
||||
assert ferro_ta.FerroTAInputError is FerroTAInputError
|
||||
|
||||
|
||||
class TestCheckTimeperiod:
|
||||
"""check_timeperiod raises FerroTAValueError with clear message."""
|
||||
|
||||
def test_valid_timeperiod_does_not_raise(self):
|
||||
check_timeperiod(1)
|
||||
check_timeperiod(14)
|
||||
check_timeperiod(100)
|
||||
|
||||
def test_zero_raises_ferro_ta_value_error(self):
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1, got 0"):
|
||||
check_timeperiod(0)
|
||||
|
||||
def test_negative_raises_ferro_ta_value_error(self):
|
||||
with pytest.raises(FerroTAValueError) as exc_info:
|
||||
check_timeperiod(-5, name="timeperiod")
|
||||
assert "timeperiod" in str(exc_info.value)
|
||||
assert "-5" in str(exc_info.value)
|
||||
|
||||
def test_custom_name_in_message(self):
|
||||
with pytest.raises(FerroTAValueError, match="fastperiod"):
|
||||
check_timeperiod(0, name="fastperiod")
|
||||
|
||||
def test_custom_minimum(self):
|
||||
with pytest.raises(FerroTAValueError, match=">= 2"):
|
||||
check_timeperiod(1, minimum=2)
|
||||
|
||||
|
||||
class TestCheckEqualLength:
|
||||
"""check_equal_length raises FerroTAInputError for mismatched arrays."""
|
||||
|
||||
def test_equal_lengths_pass(self):
|
||||
a = np.array([1.0, 2.0, 3.0])
|
||||
b = np.array([4.0, 5.0, 6.0])
|
||||
check_equal_length(open=a, close=b) # no exception
|
||||
|
||||
def test_mismatched_lengths_raise(self):
|
||||
a = np.array([1.0, 2.0, 3.0])
|
||||
b = np.array([4.0, 5.0])
|
||||
with pytest.raises(FerroTAInputError) as exc_info:
|
||||
check_equal_length(open=a, close=b)
|
||||
# message must mention the lengths
|
||||
msg = str(exc_info.value)
|
||||
assert "3" in msg
|
||||
assert "2" in msg
|
||||
|
||||
def test_three_arrays_all_different(self):
|
||||
with pytest.raises(FerroTAInputError):
|
||||
check_equal_length(
|
||||
open=np.array([1.0]),
|
||||
high=np.array([1.0, 2.0]),
|
||||
close=np.array([1.0, 2.0, 3.0]),
|
||||
)
|
||||
|
||||
|
||||
class TestCheckFinite:
|
||||
"""check_finite raises FerroTAInputError for NaN/Inf."""
|
||||
|
||||
def test_all_finite_passes(self):
|
||||
check_finite(np.array([1.0, 2.0, 3.0]))
|
||||
|
||||
def test_nan_raises(self):
|
||||
with pytest.raises(FerroTAInputError, match="NaN or Inf"):
|
||||
check_finite(np.array([1.0, float("nan"), 3.0]))
|
||||
|
||||
def test_inf_raises(self):
|
||||
with pytest.raises(FerroTAInputError, match="NaN or Inf"):
|
||||
check_finite(np.array([1.0, float("inf"), 3.0]))
|
||||
|
||||
def test_name_in_message(self):
|
||||
with pytest.raises(FerroTAInputError, match="myarray"):
|
||||
check_finite(np.array([float("nan")]), name="myarray")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backtesting utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta.analysis.backtest import (
|
||||
BacktestResult,
|
||||
backtest,
|
||||
macd_crossover_strategy,
|
||||
rsi_strategy,
|
||||
sma_crossover_strategy,
|
||||
)
|
||||
|
||||
|
||||
def _make_close(n: int = 50, seed: int = 42) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
returns = rng.normal(0.001, 0.01, n)
|
||||
return np.cumprod(1 + returns) * 100.0
|
||||
|
||||
|
||||
class TestRsiStrategy:
|
||||
"""rsi_strategy returns correct signal arrays."""
|
||||
|
||||
def test_output_shape(self):
|
||||
close = _make_close(50)
|
||||
signals = rsi_strategy(close, timeperiod=5)
|
||||
assert signals.shape == close.shape
|
||||
|
||||
def test_only_valid_signal_values(self):
|
||||
close = _make_close(50)
|
||||
signals = rsi_strategy(close, timeperiod=5)
|
||||
finite = signals[np.isfinite(signals)]
|
||||
assert set(finite).issubset({-1.0, 0.0, 1.0})
|
||||
|
||||
def test_nan_during_warmup(self):
|
||||
close = _make_close(20)
|
||||
signals = rsi_strategy(close, timeperiod=5)
|
||||
# First 5 values should be NaN (RSI warm-up)
|
||||
assert np.all(np.isnan(signals[:5]))
|
||||
|
||||
def test_invalid_timeperiod(self):
|
||||
with pytest.raises(FerroTAValueError):
|
||||
rsi_strategy(_make_close(10), timeperiod=0)
|
||||
|
||||
|
||||
class TestSmaCrossoverStrategy:
|
||||
"""sma_crossover_strategy returns signals when fast < slow."""
|
||||
|
||||
def test_output_shape(self):
|
||||
close = _make_close(60)
|
||||
signals = sma_crossover_strategy(close, fast=5, slow=20)
|
||||
assert signals.shape == close.shape
|
||||
|
||||
def test_only_valid_signal_values(self):
|
||||
close = _make_close(60)
|
||||
signals = sma_crossover_strategy(close, fast=5, slow=20)
|
||||
finite = signals[np.isfinite(signals)]
|
||||
assert set(finite).issubset({-1.0, 1.0})
|
||||
|
||||
def test_fast_must_be_less_than_slow(self):
|
||||
with pytest.raises(FerroTAValueError):
|
||||
sma_crossover_strategy(_make_close(60), fast=20, slow=10)
|
||||
|
||||
|
||||
class TestMacdCrossoverStrategy:
|
||||
"""macd_crossover_strategy returns signals from MACD line vs signal line."""
|
||||
|
||||
def test_output_shape(self):
|
||||
close = _make_close(100)
|
||||
signals = macd_crossover_strategy(
|
||||
close, fastperiod=12, slowperiod=26, signalperiod=9
|
||||
)
|
||||
assert signals.shape == close.shape
|
||||
|
||||
def test_only_valid_signal_values(self):
|
||||
close = _make_close(100)
|
||||
signals = macd_crossover_strategy(
|
||||
close, fastperiod=12, slowperiod=26, signalperiod=9
|
||||
)
|
||||
finite = signals[np.isfinite(signals)]
|
||||
assert set(finite).issubset({-1.0, 1.0})
|
||||
|
||||
def test_fastperiod_must_be_less_than_slowperiod(self):
|
||||
with pytest.raises(FerroTAValueError):
|
||||
macd_crossover_strategy(_make_close(60), fastperiod=26, slowperiod=12)
|
||||
|
||||
|
||||
class TestBacktest:
|
||||
"""backtest() produces correct BacktestResult."""
|
||||
|
||||
def test_rsi_strategy_runs(self):
|
||||
close = _make_close(100)
|
||||
result = backtest(close, strategy="rsi_30_70", timeperiod=5)
|
||||
assert isinstance(result, BacktestResult)
|
||||
|
||||
def test_output_lengths_match_input(self):
|
||||
close = _make_close(80)
|
||||
result = backtest(close, strategy="rsi_30_70", timeperiod=5)
|
||||
n = len(close)
|
||||
assert len(result.signals) == n
|
||||
assert len(result.positions) == n
|
||||
assert len(result.equity) == n
|
||||
|
||||
def test_equity_starts_near_one(self):
|
||||
close = _make_close(50)
|
||||
result = backtest(close, strategy="rsi_30_70", timeperiod=5)
|
||||
assert abs(result.equity[0] - 1.0) < 0.01
|
||||
|
||||
def test_sma_crossover_strategy_runs(self):
|
||||
close = _make_close(80)
|
||||
result = backtest(close, strategy="sma_crossover", fast=5, slow=20)
|
||||
assert isinstance(result, BacktestResult)
|
||||
assert result.n_trades >= 0
|
||||
|
||||
def test_custom_callable_strategy(self):
|
||||
def my_strategy(close, **_):
|
||||
signals = np.zeros(len(close))
|
||||
signals[len(close) // 2 :] = 1.0
|
||||
return signals
|
||||
|
||||
close = _make_close(40)
|
||||
result = backtest(close, strategy=my_strategy)
|
||||
assert isinstance(result, BacktestResult)
|
||||
assert len(result.signals) == len(close)
|
||||
|
||||
def test_unknown_strategy_raises(self):
|
||||
with pytest.raises(FerroTAValueError, match="Unknown strategy"):
|
||||
backtest(_make_close(30), strategy="nonexistent")
|
||||
|
||||
def test_too_short_input_raises(self):
|
||||
with pytest.raises(FerroTAInputError):
|
||||
backtest(np.array([1.0]))
|
||||
|
||||
def test_non_1d_input_raises(self):
|
||||
with pytest.raises(FerroTAInputError):
|
||||
backtest(np.array([[1.0, 2.0], [3.0, 4.0]]))
|
||||
|
||||
def test_n_trades_is_integer(self):
|
||||
close = _make_close(60)
|
||||
result = backtest(close, strategy="sma_crossover", fast=5, slow=15)
|
||||
assert isinstance(result.n_trades, int)
|
||||
assert result.n_trades >= 0
|
||||
|
||||
def test_macd_crossover_strategy_runs(self):
|
||||
close = _make_close(100)
|
||||
result = backtest(
|
||||
close,
|
||||
strategy="macd_crossover",
|
||||
fastperiod=12,
|
||||
slowperiod=26,
|
||||
signalperiod=9,
|
||||
)
|
||||
assert isinstance(result, BacktestResult)
|
||||
assert len(result.equity) == len(close)
|
||||
|
||||
def test_commission_reduces_equity(self):
|
||||
close = _make_close(80)
|
||||
result_no_comm = backtest(close, strategy="sma_crossover", fast=5, slow=20)
|
||||
result_with_comm = backtest(
|
||||
close,
|
||||
strategy="sma_crossover",
|
||||
fast=5,
|
||||
slow=20,
|
||||
commission_per_trade=0.01,
|
||||
)
|
||||
assert result_with_comm.final_equity <= result_no_comm.final_equity
|
||||
assert result_with_comm.final_equity < result_no_comm.final_equity or (
|
||||
result_no_comm.n_trades == 0
|
||||
)
|
||||
|
||||
def test_slippage_reduces_equity(self):
|
||||
close = _make_close(80)
|
||||
result_no_slip = backtest(close, strategy="sma_crossover", fast=5, slow=20)
|
||||
result_with_slip = backtest(
|
||||
close,
|
||||
strategy="sma_crossover",
|
||||
fast=5,
|
||||
slow=20,
|
||||
slippage_bps=10.0,
|
||||
)
|
||||
assert result_with_slip.final_equity <= result_no_slip.final_equity
|
||||
assert result_with_slip.final_equity < result_no_slip.final_equity or (
|
||||
result_no_slip.n_trades == 0
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin / Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta.core.registry import (
|
||||
FerroTARegistryError,
|
||||
get,
|
||||
list_indicators,
|
||||
register,
|
||||
run,
|
||||
unregister,
|
||||
)
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Registry: register, get, run, unregister, list_indicators."""
|
||||
|
||||
def test_builtins_registered(self):
|
||||
names = list_indicators()
|
||||
assert "SMA" in names
|
||||
assert "RSI" in names
|
||||
assert "EMA" in names
|
||||
assert "ATR" in names
|
||||
|
||||
def test_run_builtin_sma(self):
|
||||
close = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
result = run("SMA", close, timeperiod=3)
|
||||
# SMA(3) of [1,2,3,4,5]: valid at indices 2,3,4
|
||||
assert result.shape == (5,)
|
||||
assert np.isnan(result[0])
|
||||
assert abs(float(result[2]) - 2.0) < 1e-8
|
||||
|
||||
def test_run_builtin_rsi(self):
|
||||
close = np.array(
|
||||
[
|
||||
44.34,
|
||||
44.09,
|
||||
44.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
]
|
||||
)
|
||||
result = run("RSI", close, timeperiod=14)
|
||||
assert result.shape == (15,)
|
||||
|
||||
def test_get_returns_callable(self):
|
||||
fn = get("EMA")
|
||||
assert callable(fn)
|
||||
|
||||
def test_register_custom_indicator(self):
|
||||
def DOUBLE_SMA(close, timeperiod=5):
|
||||
return close * 2.0
|
||||
|
||||
register("DOUBLE_SMA", DOUBLE_SMA)
|
||||
try:
|
||||
close = np.array([1.0, 2.0, 3.0])
|
||||
result = run("DOUBLE_SMA", close, timeperiod=2)
|
||||
np.testing.assert_array_equal(result, np.array([2.0, 4.0, 6.0]))
|
||||
finally:
|
||||
unregister("DOUBLE_SMA")
|
||||
|
||||
def test_unregister_removes_indicator(self):
|
||||
def TEMP_IND(close):
|
||||
return close
|
||||
|
||||
register("TEMP_IND", TEMP_IND)
|
||||
assert "TEMP_IND" in list_indicators()
|
||||
unregister("TEMP_IND")
|
||||
assert "TEMP_IND" not in list_indicators()
|
||||
|
||||
def test_unknown_indicator_raises(self):
|
||||
with pytest.raises(FerroTARegistryError):
|
||||
get("UNKNOWN_INDICATOR_XYZ")
|
||||
|
||||
def test_run_unknown_indicator_raises(self):
|
||||
with pytest.raises(FerroTARegistryError):
|
||||
run("NO_SUCH_IND", np.array([1.0, 2.0]))
|
||||
|
||||
def test_unregister_unknown_raises(self):
|
||||
with pytest.raises(FerroTARegistryError):
|
||||
unregister("NEVER_REGISTERED")
|
||||
|
||||
def test_register_non_callable_raises(self):
|
||||
with pytest.raises(TypeError):
|
||||
register("BAD", 42) # type: ignore[arg-type]
|
||||
|
||||
def test_list_indicators_is_sorted(self):
|
||||
names = list_indicators()
|
||||
assert names == sorted(names)
|
||||
|
||||
def test_all_builtins_are_callable(self):
|
||||
for name in list_indicators():
|
||||
fn = get(name)
|
||||
assert callable(fn), f"{name} is not callable"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New Extended Indicators (KELTNER_CHANNELS, HULL_MA,
|
||||
# CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta import (
|
||||
CHANDELIER_EXIT,
|
||||
CHOPPINESS_INDEX,
|
||||
HULL_MA,
|
||||
KELTNER_CHANNELS,
|
||||
VWMA,
|
||||
)
|
||||
|
||||
_N = 30
|
||||
_C = np.cumsum(np.ones(_N)) + 40.0
|
||||
_H = _C + 0.5
|
||||
_L = _C - 0.5
|
||||
_V = np.full(_N, 1_000_000.0)
|
||||
|
||||
|
||||
class TestKeltnerChannels:
|
||||
def test_output_shapes(self):
|
||||
u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3)
|
||||
assert len(u) == len(m) == len(lo) == _N
|
||||
|
||||
def test_upper_gt_middle_gt_lower(self):
|
||||
u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3)
|
||||
valid = ~np.isnan(u)
|
||||
assert np.all(u[valid] > m[valid])
|
||||
assert np.all(m[valid] > lo[valid])
|
||||
|
||||
|
||||
class TestHullMA:
|
||||
def test_output_length(self):
|
||||
hull = HULL_MA(_C, timeperiod=4)
|
||||
assert len(hull) == _N
|
||||
|
||||
def test_leading_nans(self):
|
||||
hull = HULL_MA(_C, timeperiod=4)
|
||||
assert int(np.sum(np.isnan(hull))) >= 1
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
hull = HULL_MA(_C, timeperiod=4)
|
||||
assert np.all(np.isfinite(hull[~np.isnan(hull)]))
|
||||
|
||||
|
||||
class TestChandelierExit:
|
||||
def test_output_shapes(self):
|
||||
le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0)
|
||||
assert len(le) == len(se) == _N
|
||||
|
||||
def test_long_lt_high_short_gt_low(self):
|
||||
le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0)
|
||||
# Both outputs should have valid values after warmup
|
||||
valid_le = ~np.isnan(le)
|
||||
valid_se = ~np.isnan(se)
|
||||
assert valid_le.any()
|
||||
assert valid_se.any()
|
||||
# Long exit must be finite and positive
|
||||
assert np.all(np.isfinite(le[valid_le]))
|
||||
assert np.all(le[valid_le] > 0.0)
|
||||
# Short exit must be finite and positive
|
||||
assert np.all(np.isfinite(se[valid_se]))
|
||||
assert np.all(se[valid_se] > 0.0)
|
||||
|
||||
|
||||
class TestVWMA:
|
||||
def test_output_length(self):
|
||||
v = VWMA(_C, _V, timeperiod=5)
|
||||
assert len(v) == _N
|
||||
|
||||
def test_leading_nans(self):
|
||||
v = VWMA(_C, _V, timeperiod=5)
|
||||
assert int(np.sum(np.isnan(v))) == 4
|
||||
|
||||
def test_uniform_volume_equals_sma(self):
|
||||
"""With uniform volume, VWMA equals SMA."""
|
||||
from ferro_ta import SMA
|
||||
|
||||
c = np.arange(1.0, 21.0)
|
||||
v = np.ones(20)
|
||||
vwma = VWMA(c, v, timeperiod=5)
|
||||
sma = SMA(c, timeperiod=5)
|
||||
valid = ~np.isnan(vwma) & ~np.isnan(sma)
|
||||
assert np.allclose(vwma[valid], sma[valid], rtol=1e-9)
|
||||
|
||||
|
||||
class TestChoppinessIndex:
|
||||
def test_output_length(self):
|
||||
ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5)
|
||||
assert len(ci) == _N
|
||||
|
||||
def test_range_0_to_100(self):
|
||||
ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5)
|
||||
valid = ci[~np.isnan(ci)]
|
||||
if len(valid) > 0:
|
||||
assert np.all(valid >= 0.0)
|
||||
assert np.all(valid <= 100.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch execution API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta import EMA, RSI, SMA
|
||||
from ferro_ta.data.batch import batch_apply, batch_ema, batch_rsi, batch_sma
|
||||
|
||||
|
||||
class TestBatchSMA:
|
||||
C2D = np.random.default_rng(7).random((50, 3)) + 50.0
|
||||
C1D = C2D[:, 0]
|
||||
|
||||
def test_output_shape_2d(self):
|
||||
result = batch_sma(self.C2D, timeperiod=10)
|
||||
assert result.shape == (50, 3)
|
||||
|
||||
def test_output_shape_1d_unchanged(self):
|
||||
"""1-D input should return 1-D (backward compatible)."""
|
||||
result = batch_sma(self.C1D, timeperiod=10)
|
||||
assert result.ndim == 1
|
||||
assert len(result) == 50
|
||||
|
||||
def test_column_matches_single_series(self):
|
||||
"""Each column of batch_sma must match single-series SMA."""
|
||||
result = batch_sma(self.C2D, timeperiod=10)
|
||||
for j in range(3):
|
||||
expected = SMA(self.C2D[:, j], timeperiod=10)
|
||||
assert np.allclose(result[:, j], expected, equal_nan=True)
|
||||
|
||||
|
||||
class TestBatchEMA:
|
||||
C2D = np.random.default_rng(8).random((50, 4)) + 40.0
|
||||
|
||||
def test_output_shape(self):
|
||||
result = batch_ema(self.C2D, timeperiod=5)
|
||||
assert result.shape == (50, 4)
|
||||
|
||||
def test_column_matches_single_series(self):
|
||||
result = batch_ema(self.C2D, timeperiod=5)
|
||||
for j in range(4):
|
||||
expected = EMA(self.C2D[:, j], timeperiod=5)
|
||||
assert np.allclose(result[:, j], expected, equal_nan=True)
|
||||
|
||||
|
||||
class TestBatchRSI:
|
||||
C2D = np.random.default_rng(9).random((50, 2)) + 45.0
|
||||
|
||||
def test_output_shape(self):
|
||||
result = batch_rsi(self.C2D, timeperiod=14)
|
||||
assert result.shape == (50, 2)
|
||||
|
||||
def test_values_in_range(self):
|
||||
result = batch_rsi(self.C2D, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
if len(valid) > 0:
|
||||
assert valid.min() >= 0.0
|
||||
assert valid.max() <= 100.0
|
||||
|
||||
def test_column_matches_single_series(self):
|
||||
result = batch_rsi(self.C2D, timeperiod=14)
|
||||
for j in range(2):
|
||||
expected = RSI(self.C2D[:, j], timeperiod=14)
|
||||
assert np.allclose(result[:, j], expected, equal_nan=True)
|
||||
|
||||
|
||||
class TestBatchApply:
|
||||
C2D = np.random.default_rng(11).random((40, 3)) + 50.0
|
||||
|
||||
def test_custom_fn(self):
|
||||
"""batch_apply should delegate to any single-series function."""
|
||||
from ferro_ta import BBANDS
|
||||
|
||||
def mid(c, **kw):
|
||||
return BBANDS(c, **kw)[1]
|
||||
|
||||
result = batch_apply(self.C2D, mid, timeperiod=5)
|
||||
assert result.shape == (40, 3)
|
||||
|
||||
def test_3d_raises(self):
|
||||
with pytest.raises(ValueError, match="1-D or 2-D"):
|
||||
batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Release playbook and version consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import os
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[no-redef] # fallback for Python < 3.11
|
||||
except ImportError:
|
||||
tomllib = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _read_cargo_version() -> str:
|
||||
"""Extract version from root Cargo.toml."""
|
||||
if tomllib is None:
|
||||
raise ImportError("tomllib/tomli not available")
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
cargo_toml = os.path.join(root, "Cargo.toml")
|
||||
with open(cargo_toml, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data["package"]["version"]
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str:
|
||||
"""Extract version from pyproject.toml."""
|
||||
if tomllib is None:
|
||||
raise ImportError("tomllib/tomli not available")
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
pyproject_toml = os.path.join(root, "pyproject.toml")
|
||||
with open(pyproject_toml, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data["project"]["version"]
|
||||
|
||||
|
||||
class TestVersionConsistency:
|
||||
"""Cargo.toml and pyproject.toml must have the same version string."""
|
||||
|
||||
def test_versions_match(self):
|
||||
try:
|
||||
cargo_ver = _read_cargo_version()
|
||||
pyproject_ver = _read_pyproject_version()
|
||||
except Exception:
|
||||
pytest.skip("tomllib unavailable or files not found")
|
||||
assert cargo_ver == pyproject_ver, (
|
||||
f"Version mismatch: Cargo.toml={cargo_ver!r}, "
|
||||
f"pyproject.toml={pyproject_ver!r}"
|
||||
)
|
||||
|
||||
def test_release_md_exists(self):
|
||||
"""RELEASE.md must exist in the repository root."""
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
release_md = os.path.join(root, "RELEASE.md")
|
||||
assert os.path.isfile(release_md), "RELEASE.md not found"
|
||||
|
||||
def test_release_md_has_key_sections(self):
|
||||
"""RELEASE.md must mention tagging and PyPI."""
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
release_md = os.path.join(root, "RELEASE.md")
|
||||
if not os.path.isfile(release_md):
|
||||
pytest.skip("RELEASE.md not found")
|
||||
text = open(release_md).read()
|
||||
assert "git tag" in text or "tag" in text.lower()
|
||||
assert "pypi" in text.lower() or "PyPI" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU backend (PyTorch, CPU fallback always available)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta.tools.gpu import ema as gpu_ema
|
||||
from ferro_ta.tools.gpu import rsi as gpu_rsi
|
||||
from ferro_ta.tools.gpu import sma as gpu_sma # noqa: E402
|
||||
|
||||
CLOSE_15 = np.array(
|
||||
[
|
||||
44.34,
|
||||
44.09,
|
||||
44.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestGPUCPUFallback:
|
||||
"""GPU module falls back to CPU when CuPy is not available."""
|
||||
|
||||
def test_sma_cpu_fallback_length(self):
|
||||
result = gpu_sma(CLOSE_15, timeperiod=5)
|
||||
assert len(result) == len(CLOSE_15)
|
||||
|
||||
def test_sma_cpu_fallback_values(self):
|
||||
from ferro_ta import SMA
|
||||
|
||||
result = gpu_sma(CLOSE_15, timeperiod=5)
|
||||
expected = SMA(CLOSE_15, timeperiod=5)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
def test_ema_cpu_fallback_values(self):
|
||||
from ferro_ta import EMA
|
||||
|
||||
result = gpu_ema(CLOSE_15, timeperiod=5)
|
||||
expected = EMA(CLOSE_15, timeperiod=5)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
def test_rsi_cpu_fallback_values(self):
|
||||
from ferro_ta import RSI
|
||||
|
||||
result = gpu_rsi(CLOSE_15, timeperiod=5)
|
||||
expected = RSI(CLOSE_15, timeperiod=5)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
def test_sma_returns_numpy_for_numpy_input(self):
|
||||
result = gpu_sma(CLOSE_15, timeperiod=5)
|
||||
assert isinstance(result, np.ndarray)
|
||||
|
||||
def test_rsi_finite_values_in_range(self):
|
||||
result = gpu_rsi(CLOSE_15, timeperiod=5)
|
||||
finite = result[np.isfinite(result)]
|
||||
assert len(finite) > 0
|
||||
assert np.all(finite >= 0.0)
|
||||
assert np.all(finite <= 100.0)
|
||||
|
||||
def test_gpu_module_all_exports(self):
|
||||
from ferro_ta.tools import gpu as gpu_mod
|
||||
|
||||
for name in gpu_mod.__all__:
|
||||
assert callable(getattr(gpu_mod, name))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Indicator pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta import BBANDS # noqa: E402 (already imported)
|
||||
from ferro_ta.tools.pipeline import Pipeline, make_pipeline # noqa: E402
|
||||
|
||||
CLOSE_20 = np.random.default_rng(99).random(20) * 100 + 50
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
"""Tests for ferro_ta.pipeline.Pipeline."""
|
||||
|
||||
def test_pipeline_run_returns_dict(self):
|
||||
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
|
||||
result = pipe.run(CLOSE_20)
|
||||
assert isinstance(result, dict)
|
||||
assert "sma5" in result
|
||||
|
||||
def test_pipeline_result_length_matches_input(self):
|
||||
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
|
||||
result = pipe.run(CLOSE_20)
|
||||
assert len(result["sma5"]) == len(CLOSE_20)
|
||||
|
||||
def test_pipeline_multiple_steps(self):
|
||||
pipe = (
|
||||
Pipeline()
|
||||
.add("sma5", SMA, timeperiod=5)
|
||||
.add("ema5", EMA, timeperiod=5)
|
||||
.add("rsi7", RSI, timeperiod=7)
|
||||
)
|
||||
result = pipe.run(CLOSE_20)
|
||||
assert set(result.keys()) == {"sma5", "ema5", "rsi7"}
|
||||
|
||||
def test_pipeline_multi_output_with_output_keys(self):
|
||||
pipe = Pipeline().add(
|
||||
"bb",
|
||||
BBANDS,
|
||||
timeperiod=5,
|
||||
nbdevup=2.0,
|
||||
nbdevdn=2.0,
|
||||
output_keys=["upper", "mid", "lower"],
|
||||
)
|
||||
result = pipe.run(CLOSE_20)
|
||||
assert "upper" in result
|
||||
assert "mid" in result
|
||||
assert "lower" in result
|
||||
assert "bb" not in result
|
||||
|
||||
def test_pipeline_multi_output_without_output_keys(self):
|
||||
pipe = Pipeline().add("bb", BBANDS, timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
|
||||
result = pipe.run(CLOSE_20)
|
||||
# Should auto-name as bb_0, bb_1, bb_2
|
||||
assert "bb_0" in result
|
||||
assert "bb_1" in result
|
||||
assert "bb_2" in result
|
||||
|
||||
def test_pipeline_remove_step(self):
|
||||
pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5)
|
||||
pipe.remove("sma5")
|
||||
assert pipe.steps() == ["ema5"]
|
||||
|
||||
def test_pipeline_len(self):
|
||||
pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5)
|
||||
assert len(pipe) == 2
|
||||
|
||||
def test_pipeline_duplicate_name_raises(self):
|
||||
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
|
||||
with pytest.raises(ValueError, match="sma5"):
|
||||
pipe.add("sma5", SMA, timeperiod=10)
|
||||
|
||||
def test_make_pipeline_factory(self):
|
||||
pipe = make_pipeline(
|
||||
sma5=(SMA, {"timeperiod": 5}),
|
||||
rsi7=(RSI, {"timeperiod": 7}),
|
||||
)
|
||||
result = pipe.run(CLOSE_20)
|
||||
assert "sma5" in result
|
||||
assert "rsi7" in result
|
||||
|
||||
def test_pipeline_sma_values_match_direct_call(self):
|
||||
pipe = Pipeline().add("sma5", SMA, timeperiod=5)
|
||||
result = pipe.run(CLOSE_20)
|
||||
direct = SMA(CLOSE_20, timeperiod=5)
|
||||
np.testing.assert_allclose(result["sma5"], direct, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polars integration (skipped if polars not installed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPolarsIntegration:
|
||||
"""Transparent polars.Series support via polars_wrap."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def skip_if_no_polars(self):
|
||||
pytest.importorskip("polars")
|
||||
|
||||
def test_sma_returns_polars_series(self):
|
||||
import polars as pl
|
||||
|
||||
s = pl.Series("close", CLOSE_20.tolist())
|
||||
result = SMA(s, timeperiod=5)
|
||||
assert isinstance(result, pl.Series)
|
||||
|
||||
def test_sma_values_match_numpy(self):
|
||||
import polars as pl
|
||||
|
||||
s = pl.Series("close", CLOSE_20.tolist())
|
||||
result = SMA(s, timeperiod=5)
|
||||
expected = SMA(CLOSE_20, timeperiod=5)
|
||||
np.testing.assert_allclose(result.to_numpy(), expected, equal_nan=True)
|
||||
|
||||
def test_rsi_returns_polars_series(self):
|
||||
import polars as pl
|
||||
|
||||
s = pl.Series("close", CLOSE_20.tolist())
|
||||
result = RSI(s, timeperiod=5)
|
||||
assert isinstance(result, pl.Series)
|
||||
|
||||
def test_numpy_input_still_returns_numpy(self):
|
||||
result = SMA(CLOSE_20, timeperiod=5)
|
||||
assert isinstance(result, np.ndarray)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import ferro_ta.core.config as ftconfig # noqa: E402
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Tests for ferro_ta.config module."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset config state before each test."""
|
||||
ftconfig.reset()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after each test."""
|
||||
ftconfig.reset()
|
||||
|
||||
def test_set_and_get_default(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
assert ftconfig.get_default("timeperiod") == 20
|
||||
|
||||
def test_get_default_fallback(self):
|
||||
assert ftconfig.get_default("nonexistent") is None
|
||||
assert ftconfig.get_default("nonexistent", -1) == -1
|
||||
|
||||
def test_reset_single_key(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
ftconfig.reset("timeperiod")
|
||||
assert ftconfig.get_default("timeperiod") is None
|
||||
|
||||
def test_reset_all(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
ftconfig.set_default("RSI.timeperiod", 14)
|
||||
ftconfig.reset()
|
||||
assert ftconfig.list_defaults() == {}
|
||||
|
||||
def test_list_defaults(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
ftconfig.set_default("RSI.timeperiod", 14)
|
||||
defaults = ftconfig.list_defaults()
|
||||
assert defaults == {"timeperiod": 20, "RSI.timeperiod": 14}
|
||||
|
||||
def test_get_defaults_for_indicator(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
ftconfig.set_default("RSI.timeperiod", 14)
|
||||
rsi_defaults = ftconfig.get_defaults_for("RSI")
|
||||
assert rsi_defaults == {"timeperiod": 14}
|
||||
sma_defaults = ftconfig.get_defaults_for("SMA")
|
||||
assert sma_defaults == {"timeperiod": 20}
|
||||
|
||||
def test_config_context_manager(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
with ftconfig.Config(timeperiod=5):
|
||||
assert ftconfig.get_default("timeperiod") == 5
|
||||
assert ftconfig.get_default("timeperiod") == 20
|
||||
|
||||
def test_config_context_manager_restores_on_exception(self):
|
||||
ftconfig.set_default("timeperiod", 20)
|
||||
try:
|
||||
with ftconfig.Config(timeperiod=5):
|
||||
raise RuntimeError("test error")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert ftconfig.get_default("timeperiod") == 20
|
||||
|
||||
def test_config_context_manager_new_key_removed_on_exit(self):
|
||||
# Key doesn't exist before context
|
||||
assert ftconfig.get_default("nbdevup") is None
|
||||
with ftconfig.Config(nbdevup=2.5):
|
||||
assert ftconfig.get_default("nbdevup") == 2.5
|
||||
assert ftconfig.get_default("nbdevup") is None
|
||||
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
Known-value oracle tests: permanent ground truth (Priority 2 - no optional deps).
|
||||
|
||||
Hand-computable ground truth that never depends on external libraries.
|
||||
These tests encode fundamental mathematical properties and serve as a permanent
|
||||
oracle for correctness.
|
||||
|
||||
All tests use NO optional dependencies - they run in every CI environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ferro_ta
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMA Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSMAKnownValues:
|
||||
"""SMA is the simple average over a window."""
|
||||
|
||||
def test_sma_simple_sequence(self):
|
||||
"""SMA([1,2,3,4,5], 3) == [nan, nan, 2.0, 3.0, 4.0]."""
|
||||
data = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
result = ferro_ta.SMA(data, timeperiod=3)
|
||||
|
||||
assert np.isnan(result[0])
|
||||
assert np.isnan(result[1])
|
||||
assert np.abs(result[2] - 2.0) < 1e-10 # (1+2+3)/3 = 2.0
|
||||
assert np.abs(result[3] - 3.0) < 1e-10 # (2+3+4)/3 = 3.0
|
||||
assert np.abs(result[4] - 4.0) < 1e-10 # (3+4+5)/3 = 4.0
|
||||
|
||||
def test_sma_period_one_is_identity(self):
|
||||
"""SMA with period=1 should be the identity function."""
|
||||
data = np.array([10.0, 12.0, 15.0, 11.0, 13.0])
|
||||
result = ferro_ta.SMA(data, timeperiod=1)
|
||||
|
||||
assert np.allclose(result, data, atol=1e-10)
|
||||
|
||||
def test_sma_constant_series(self):
|
||||
"""SMA of constant series should equal that constant."""
|
||||
data = np.ones(10) * 42.0
|
||||
result = ferro_ta.SMA(data, timeperiod=5)
|
||||
|
||||
# After warmup, all values should be 42.0
|
||||
assert np.allclose(result[4:], 42.0, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EMA Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEMAKnownValues:
|
||||
"""EMA is an exponentially weighted moving average."""
|
||||
|
||||
def test_ema_period_one_is_identity(self):
|
||||
"""EMA with period=1 should be the identity function (alpha=1)."""
|
||||
data = np.array([10.0, 12.0, 15.0, 11.0, 13.0])
|
||||
result = ferro_ta.EMA(data, timeperiod=1)
|
||||
|
||||
assert np.allclose(result, data, atol=1e-10)
|
||||
|
||||
def test_ema_constant_series_converges(self):
|
||||
"""EMA of constant series should converge to that constant."""
|
||||
data = np.ones(100) * 42.0
|
||||
result = ferro_ta.EMA(data, timeperiod=10)
|
||||
|
||||
# After sufficient warmup, should converge to 42.0
|
||||
assert np.allclose(result[-10:], 42.0, atol=1e-6)
|
||||
|
||||
def test_ema_monotone_rising_is_increasing(self):
|
||||
"""EMA of monotone rising series should be strictly increasing after warmup."""
|
||||
data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50
|
||||
result = ferro_ta.EMA(data, timeperiod=10)
|
||||
|
||||
# After warmup, EMA should be strictly increasing
|
||||
for i in range(20, len(result) - 1):
|
||||
assert result[i + 1] > result[i], (
|
||||
f"EMA not increasing at index {i}: {result[i]} >= {result[i+1]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WMA Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWMAKnownValues:
|
||||
"""WMA is a linearly weighted moving average."""
|
||||
|
||||
def test_wma_manual_calculation(self):
|
||||
"""WMA([3,5,7], 2) at index 2 = (1*5 + 2*7)/(1+2) = 6.333..."""
|
||||
data = np.array([3.0, 5.0, 7.0])
|
||||
result = ferro_ta.WMA(data, timeperiod=2)
|
||||
|
||||
# Index 0: warmup (NaN)
|
||||
assert np.isnan(result[0])
|
||||
|
||||
# Index 1: (1*3 + 2*5)/(1+2) = 13/3 = 4.333...
|
||||
expected_1 = (1 * 3.0 + 2 * 5.0) / (1 + 2)
|
||||
assert np.abs(result[1] - expected_1) < 1e-10
|
||||
|
||||
# Index 2: (1*5 + 2*7)/(1+2) = 19/3 = 6.333...
|
||||
expected_2 = (1 * 5.0 + 2 * 7.0) / (1 + 2)
|
||||
assert np.abs(result[2] - expected_2) < 1e-10
|
||||
|
||||
def test_wma_period_one_is_identity(self):
|
||||
"""WMA with period=1 should be the identity function."""
|
||||
data = np.array([10.0, 12.0, 15.0, 11.0, 13.0])
|
||||
result = ferro_ta.WMA(data, timeperiod=1)
|
||||
|
||||
assert np.allclose(result, data, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBANDS Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBBANDSKnownValues:
|
||||
"""Bollinger Bands: middle = SMA, upper/lower = middle ± (nbdevup/nbdevdn * stddev)."""
|
||||
|
||||
def test_bbands_constant_series(self):
|
||||
"""For constant series: upper == middle == lower (stddev=0)."""
|
||||
data = np.ones(20) * 50.0
|
||||
upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5)
|
||||
|
||||
# After warmup, all three bands should be 50.0
|
||||
assert np.allclose(upper[4:], 50.0, atol=1e-10)
|
||||
assert np.allclose(middle[4:], 50.0, atol=1e-10)
|
||||
assert np.allclose(lower[4:], 50.0, atol=1e-10)
|
||||
|
||||
def test_bbands_middle_is_sma(self):
|
||||
"""Middle band should equal SMA."""
|
||||
data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0])
|
||||
upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5)
|
||||
sma = ferro_ta.SMA(data, timeperiod=5)
|
||||
|
||||
assert np.allclose(middle, sma, atol=1e-10, equal_nan=True)
|
||||
|
||||
def test_bbands_symmetric(self):
|
||||
"""Bands should be symmetric: upper-middle == middle-lower (with same nbdev)."""
|
||||
data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0, 18.0, 10.0])
|
||||
upper, middle, lower = ferro_ta.BBANDS(
|
||||
data, timeperiod=5, nbdevup=2.0, nbdevdn=2.0
|
||||
)
|
||||
|
||||
# After warmup, bands should be symmetric
|
||||
upper_dist = upper[4:] - middle[4:]
|
||||
lower_dist = middle[4:] - lower[4:]
|
||||
|
||||
assert np.allclose(upper_dist, lower_dist, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSI Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRSIKnownValues:
|
||||
"""RSI measures momentum: monotone rising → RSI > 50, monotone falling → RSI < 50."""
|
||||
|
||||
def test_rsi_monotone_rising(self):
|
||||
"""Monotone rising series should produce RSI > 50 after warmup."""
|
||||
data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50
|
||||
result = ferro_ta.RSI(data, timeperiod=14)
|
||||
|
||||
# After warmup, RSI should be > 50 (strong uptrend)
|
||||
assert np.all(result[20:] > 50.0), "RSI of rising series should be > 50"
|
||||
|
||||
def test_rsi_monotone_falling(self):
|
||||
"""Monotone falling series should produce RSI < 50 after warmup."""
|
||||
data = np.arange(50.0, 0.0, -1.0) # 50, 49, 48, ..., 1
|
||||
result = ferro_ta.RSI(data, timeperiod=14)
|
||||
|
||||
# After warmup, RSI should be < 50 (strong downtrend)
|
||||
assert np.all(result[20:] < 50.0), "RSI of falling series should be < 50"
|
||||
|
||||
def test_rsi_constant_series(self):
|
||||
"""Constant series should produce RSI = 100 or NaN (no momentum).
|
||||
|
||||
Note: For constant series with no change, ferro_ta returns 100
|
||||
(no downward movement), which is mathematically correct.
|
||||
"""
|
||||
data = np.ones(30) * 42.0
|
||||
result = ferro_ta.RSI(data, timeperiod=14)
|
||||
|
||||
# Constant series has no momentum; RSI should be NaN or 100
|
||||
# ferro_ta returns 100 (no down movement = 100% bullish)
|
||||
valid_values = result[~np.isnan(result)]
|
||||
if len(valid_values) > 0:
|
||||
# Should be either NaN everywhere or 100 everywhere
|
||||
assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all(np.abs(valid_values - 50.0) < 5.0), (
|
||||
"RSI of constant series should be 100 (no down movement) or close to 50"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ATR Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestATRKnownValues:
|
||||
"""ATR measures volatility: H==L==C → ATR=0."""
|
||||
|
||||
def test_atr_zero_range(self):
|
||||
"""When H==L==C, ATR should be 0 (no volatility)."""
|
||||
n = 30
|
||||
high = np.ones(n) * 50.0
|
||||
low = np.ones(n) * 50.0
|
||||
close = np.ones(n) * 50.0
|
||||
|
||||
result = ferro_ta.ATR(high, low, close, timeperiod=14)
|
||||
|
||||
# After warmup, ATR should be 0
|
||||
assert np.allclose(result[14:], 0.0, atol=1e-10)
|
||||
|
||||
def test_atr_manual_tr_calculation(self):
|
||||
"""Manually verify TR formula for 3-bar sequence.
|
||||
|
||||
Note: ATR requires warmup period. For period=14, first 13 bars are NaN.
|
||||
We test with longer period to see TR values.
|
||||
"""
|
||||
# Bar 0: H=11, L=9, C=10
|
||||
# Bar 1: H=13, L=10, C=12 → TR = max(13-10, |13-10|, |10-10|) = 3
|
||||
# Bar 2: H=14, L=11, C=13 → TR = max(14-11, |14-12|, |11-12|) = 3
|
||||
high = np.array([11.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0,
|
||||
22.0, 23.0, 24.0, 25.0, 26.0])
|
||||
low = np.array([9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0,
|
||||
19.0, 20.0, 21.0, 22.0, 23.0])
|
||||
close = np.array([10.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0,
|
||||
21.0, 22.0, 23.0, 24.0, 25.0])
|
||||
|
||||
# For period=1, ATR still has warmup. Use TRANGE to check TR values directly
|
||||
tr = ferro_ta.TRANGE(high, low, close)
|
||||
|
||||
# TR[0] = H-L = 11-9 = 2
|
||||
# TR[1] = max(13-10, |13-10|, |10-10|) = max(3, 3, 0) = 3
|
||||
# TR[2] = max(14-11, |14-12|, |11-12|) = max(3, 2, 1) = 3
|
||||
|
||||
assert np.abs(tr[0] - 2.0) < 1e-10
|
||||
assert np.abs(tr[1] - 3.0) < 1e-10
|
||||
assert np.abs(tr[2] - 3.0) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MOM Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMOMKnownValues:
|
||||
"""MOM is the difference: close[i] - close[i - period]."""
|
||||
|
||||
def test_mom_manual_calculation(self):
|
||||
"""MOM([10,12,15,11], period=2) == [nan,nan,5,-1]."""
|
||||
data = np.array([10.0, 12.0, 15.0, 11.0])
|
||||
result = ferro_ta.MOM(data, timeperiod=2)
|
||||
|
||||
assert np.isnan(result[0])
|
||||
assert np.isnan(result[1])
|
||||
assert np.abs(result[2] - 5.0) < 1e-10 # 15 - 10 = 5
|
||||
assert np.abs(result[3] - (-1.0)) < 1e-10 # 11 - 12 = -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROC Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCKnownValues:
|
||||
"""ROC is the percentage change: 100 * (close[i] - close[i-period]) / close[i-period]."""
|
||||
|
||||
def test_roc_manual_calculation(self):
|
||||
"""ROC([10,12], period=1)[1] == 20.0."""
|
||||
data = np.array([10.0, 12.0])
|
||||
result = ferro_ta.ROC(data, timeperiod=1)
|
||||
|
||||
# ROC[1] = 100 * (12 - 10) / 10 = 100 * 0.2 = 20.0
|
||||
assert np.abs(result[1] - 20.0) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MACD Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMACDKnownValues:
|
||||
"""MACD: histogram == macd - signal always."""
|
||||
|
||||
def test_macd_histogram_identity(self):
|
||||
"""histogram should always equal macd - signal."""
|
||||
data = np.arange(1.0, 51.0)
|
||||
macd, signal, histogram = ferro_ta.MACD(data, fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
|
||||
# histogram = macd - signal (within floating-point tolerance)
|
||||
expected_histogram = macd - signal
|
||||
assert np.allclose(histogram, expected_histogram, atol=1e-10, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VWAP Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVWAPKnownValues:
|
||||
"""VWAP: period=1 VWAP == TYPPRICE."""
|
||||
|
||||
def test_vwap_period_one_equals_typprice(self):
|
||||
"""For period=1, VWAP should equal typical price (H+L+C)/3."""
|
||||
high = np.array([11.0, 13.0, 14.0])
|
||||
low = np.array([9.0, 10.0, 11.0])
|
||||
close = np.array([10.0, 12.0, 13.0])
|
||||
volume = np.array([1000.0, 1000.0, 1000.0])
|
||||
|
||||
result = ferro_ta.VWAP(high, low, close, volume, timeperiod=1)
|
||||
expected = ferro_ta.TYPPRICE(high, low, close)
|
||||
|
||||
assert np.allclose(result, expected, atol=1e-10)
|
||||
|
||||
def test_vwap_cumulative_manual(self):
|
||||
"""Manually verify cumulative VWAP for simple 3-bar sequence."""
|
||||
# Bar 0: TP=10, Vol=100 → VWAP = (10*100)/(100) = 10.0
|
||||
# Bar 1: TP=12, Vol=200 → VWAP = (10*100 + 12*200)/(100+200) = 3400/300 = 11.333...
|
||||
# Bar 2: TP=11, Vol=150 → VWAP = (10*100 + 12*200 + 11*150)/(100+200+150) = 5050/450 = 11.222...
|
||||
high = np.array([11.0, 13.0, 12.0])
|
||||
low = np.array([9.0, 11.0, 10.0])
|
||||
close = np.array([10.0, 12.0, 11.0])
|
||||
volume = np.array([100.0, 200.0, 150.0])
|
||||
|
||||
result = ferro_ta.VWAP(high, low, close, volume, timeperiod=0) # cumulative
|
||||
|
||||
typ = (high + low + close) / 3.0
|
||||
|
||||
expected_0 = typ[0]
|
||||
expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1])
|
||||
expected_2 = (
|
||||
typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]
|
||||
) / (volume[0] + volume[1] + volume[2])
|
||||
|
||||
assert np.abs(result[0] - expected_0) < 1e-10
|
||||
assert np.abs(result[1] - expected_1) < 1e-10
|
||||
assert np.abs(result[2] - expected_2) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DONCHIAN Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDONCHIANKnownValues:
|
||||
"""DONCHIAN: upper = MAX(high), lower = MIN(low), middle = (upper+lower)/2."""
|
||||
|
||||
def test_donchian_structure(self):
|
||||
"""upper == MAX(high), lower == MIN(low), middle == (upper+lower)/2."""
|
||||
high = np.array([11.0, 13.0, 14.0, 12.0, 15.0])
|
||||
low = np.array([9.0, 10.0, 11.0, 10.0, 12.0])
|
||||
close = np.array([10.0, 12.0, 13.0, 11.0, 14.0])
|
||||
|
||||
period = 3
|
||||
upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period)
|
||||
|
||||
# upper should match rolling max of high
|
||||
max_high = ferro_ta.MAX(high, timeperiod=period)
|
||||
assert np.allclose(upper, max_high, atol=1e-10, equal_nan=True)
|
||||
|
||||
# lower should match rolling min of low
|
||||
min_low = ferro_ta.MIN(low, timeperiod=period)
|
||||
assert np.allclose(lower, min_low, atol=1e-10, equal_nan=True)
|
||||
|
||||
# middle should be (upper + lower) / 2
|
||||
expected_middle = (upper + lower) / 2.0
|
||||
assert np.allclose(middle, expected_middle, atol=1e-10, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PIVOT_POINTS Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPIVOT_POINTSKnownValues:
|
||||
"""PIVOT_POINTS classic formula: P=(H+L+C)/3, R1=2P-L, S1=2P-H, R2=P+(H-L), S2=P-(H-L)."""
|
||||
|
||||
def test_pivot_points_classic_formula(self):
|
||||
"""Given H=110, L=90, C=100: P=100, R1=110, S1=90, R2=120, S2=80.
|
||||
|
||||
Note: PIVOT_POINTS operates on OHLC bars. Single bar produces valid pivots.
|
||||
"""
|
||||
high = np.array([110.0, 110.0]) # Need at least 2 bars
|
||||
low = np.array([90.0, 90.0])
|
||||
close = np.array([100.0, 100.0])
|
||||
|
||||
pivot, r1, s1, r2, s2 = ferro_ta.PIVOT_POINTS(high, low, close, method="classic")
|
||||
|
||||
# Check last bar (index 1) which has full history
|
||||
# P = (110 + 90 + 100) / 3 = 100
|
||||
assert np.abs(pivot[1] - 100.0) < 1e-10
|
||||
|
||||
# R1 = 2*P - L = 2*100 - 90 = 110
|
||||
assert np.abs(r1[1] - 110.0) < 1e-10
|
||||
|
||||
# S1 = 2*P - H = 2*100 - 110 = 90
|
||||
assert np.abs(s1[1] - 90.0) < 1e-10
|
||||
|
||||
# R2 = P + (H - L) = 100 + 20 = 120
|
||||
assert np.abs(r2[1] - 120.0) < 1e-10
|
||||
|
||||
# S2 = P - (H - L) = 100 - 20 = 80
|
||||
assert np.abs(s2[1] - 80.0) < 1e-10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Statistic Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStatisticKnownValues:
|
||||
"""Statistical functions: correlation, linear regression."""
|
||||
|
||||
def test_linearreg_slope_of_linear_sequence(self):
|
||||
"""LINEARREG_SLOPE([0,1,2,3,4], 5) == 1.0."""
|
||||
data = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
|
||||
result = ferro_ta.LINEARREG_SLOPE(data, timeperiod=5)
|
||||
|
||||
# Last value should be slope = 1.0
|
||||
assert np.abs(result[-1] - 1.0) < 1e-10
|
||||
|
||||
def test_correl_x_with_x_is_one(self):
|
||||
"""CORREL(x, x) should be 1.0."""
|
||||
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
|
||||
result = ferro_ta.CORREL(x, x, timeperiod=5)
|
||||
|
||||
# After warmup, correlation should be 1.0
|
||||
assert np.allclose(result[4:], 1.0, atol=1e-10)
|
||||
|
||||
def test_correl_x_with_negative_x_is_minus_one(self):
|
||||
"""CORREL(x, -x) should be -1.0."""
|
||||
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
|
||||
neg_x = -x
|
||||
result = ferro_ta.CORREL(x, neg_x, timeperiod=5)
|
||||
|
||||
# After warmup, correlation should be -1.0
|
||||
assert np.allclose(result[4:], -1.0, atol=1e-10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern Known Values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPatternKnownValues:
|
||||
"""Candlestick patterns: construct known-good OHLC sequences."""
|
||||
|
||||
def test_doji_known_sequence(self):
|
||||
"""Construct a perfect doji: open == close, small body."""
|
||||
# Doji: open == close (or very close), H and L have range
|
||||
n = 5
|
||||
high = np.array([11.0, 11.0, 11.0, 11.0, 11.0])
|
||||
low = np.array([9.0, 9.0, 9.0, 9.0, 9.0])
|
||||
close = np.array([10.0, 10.0, 10.0, 10.0, 10.0])
|
||||
open_ = np.array([10.0, 10.0, 10.0, 10.0, 10.0])
|
||||
|
||||
result = ferro_ta.CDLDOJI(open_, high, low, close)
|
||||
|
||||
# Should detect doji (non-zero pattern)
|
||||
# At least some values should be non-zero
|
||||
assert np.any(result != 0), "CDLDOJI should detect perfect doji pattern"
|
||||
|
||||
def test_engulfing_known_sequence(self):
|
||||
"""Construct a bullish engulfing pattern."""
|
||||
# Bullish engulfing: bar[i-1] is bearish (O > C), bar[i] is bullish (C > O) and engulfs bar[i-1]
|
||||
# Bar 0: O=12, H=12, L=10, C=10 (bearish)
|
||||
# Bar 1: O=9, H=13, L=9, C=13 (bullish, engulfs bar 0)
|
||||
open_ = np.array([12.0, 9.0])
|
||||
high = np.array([12.0, 13.0])
|
||||
low = np.array([10.0, 9.0])
|
||||
close = np.array([10.0, 13.0])
|
||||
|
||||
result = ferro_ta.CDLENGULFING(open_, high, low, close)
|
||||
|
||||
# Should detect engulfing at index 1
|
||||
assert result[1] != 0, "CDLENGULFING should detect bullish engulfing pattern"
|
||||
|
||||
def test_hammer_known_sequence(self):
|
||||
"""Construct a hammer pattern: small body at top, long lower shadow."""
|
||||
# Hammer: small body, long lower shadow (>= 2x body), little/no upper shadow
|
||||
# O=11, H=11.5, L=9, C=11 → body=0, lower_shadow=2, upper_shadow=0.5
|
||||
open_ = np.array([11.0])
|
||||
high = np.array([11.5])
|
||||
low = np.array([9.0])
|
||||
close = np.array([11.0])
|
||||
|
||||
result = ferro_ta.CDLHAMMER(open_, high, low, close)
|
||||
|
||||
# Should detect hammer (non-zero)
|
||||
# Note: hammer detection depends on lookback, so we test multiple bars
|
||||
open_ = np.array([10.0, 10.5, 11.0])
|
||||
high = np.array([10.5, 11.0, 11.5])
|
||||
low = np.array([9.5, 10.0, 9.0])
|
||||
close = np.array([10.0, 10.5, 11.0])
|
||||
|
||||
result = ferro_ta.CDLHAMMER(open_, high, low, close)
|
||||
|
||||
# Last bar has hammer characteristics
|
||||
# (actual detection may vary based on implementation)
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Comparison tests: ferro_ta.math_ops vs NumPy (Priority 1 - no optional deps).
|
||||
|
||||
Math operators should be exact numpy wrappers. Zero tolerance for deviation.
|
||||
|
||||
This module validates that all math operators and transforms in ferro_ta.math_ops
|
||||
produce identical results to their NumPy equivalents within strict tolerances:
|
||||
- Element-wise transforms: atol=1e-14 (direct numpy calls)
|
||||
- Binary operators: atol=1e-14 (direct numpy calls)
|
||||
- Rolling operators: atol=1e-12 (float sum reordering)
|
||||
- Index operators: exact index matching
|
||||
|
||||
All tests use NO optional dependencies - they run in every CI environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators import math_ops
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Data (seeded for reproducibility)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(42)
|
||||
N = 100
|
||||
|
||||
# Standard test data
|
||||
CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5)
|
||||
CLOSE_POSITIVE = np.abs(CLOSE) + 1.0 # For SQRT, LN, LOG10
|
||||
CLOSE_NORMALIZED = CLOSE / np.max(np.abs(CLOSE)) # For ASIN, ACOS (range [-1, 1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Element-wise Transform Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestElementWiseTransforms:
|
||||
"""Test all 15 unary math transforms against NumPy equivalents.
|
||||
|
||||
Expected tolerance: atol=1e-14 (direct numpy calls)
|
||||
"""
|
||||
|
||||
def test_sin_exact_match(self):
|
||||
"""SIN should match np.sin exactly."""
|
||||
result = math_ops.SIN(CLOSE)
|
||||
expected = np.sin(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_cos_exact_match(self):
|
||||
"""COS should match np.cos exactly."""
|
||||
result = math_ops.COS(CLOSE)
|
||||
expected = np.cos(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_tan_exact_match(self):
|
||||
"""TAN should match np.tan exactly."""
|
||||
result = math_ops.TAN(CLOSE)
|
||||
expected = np.tan(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_sinh_exact_match(self):
|
||||
"""SINH should match np.sinh exactly."""
|
||||
result = math_ops.SINH(CLOSE)
|
||||
expected = np.sinh(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_cosh_exact_match(self):
|
||||
"""COSH should match np.cosh exactly."""
|
||||
result = math_ops.COSH(CLOSE)
|
||||
expected = np.cosh(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_tanh_exact_match(self):
|
||||
"""TANH should match np.tanh exactly."""
|
||||
result = math_ops.TANH(CLOSE)
|
||||
expected = np.tanh(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_asin_exact_match(self):
|
||||
"""ASIN should match np.arcsin exactly."""
|
||||
result = math_ops.ASIN(CLOSE_NORMALIZED)
|
||||
expected = np.arcsin(CLOSE_NORMALIZED)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_acos_exact_match(self):
|
||||
"""ACOS should match np.arccos exactly."""
|
||||
result = math_ops.ACOS(CLOSE_NORMALIZED)
|
||||
expected = np.arccos(CLOSE_NORMALIZED)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_atan_exact_match(self):
|
||||
"""ATAN should match np.arctan exactly."""
|
||||
result = math_ops.ATAN(CLOSE)
|
||||
expected = np.arctan(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_exp_exact_match(self):
|
||||
"""EXP should match np.exp exactly."""
|
||||
# Use smaller values to avoid overflow
|
||||
small_values = CLOSE / 10.0
|
||||
result = math_ops.EXP(small_values)
|
||||
expected = np.exp(small_values)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_ln_exact_match(self):
|
||||
"""LN should match np.log exactly."""
|
||||
result = math_ops.LN(CLOSE_POSITIVE)
|
||||
expected = np.log(CLOSE_POSITIVE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_log10_exact_match(self):
|
||||
"""LOG10 should match np.log10 exactly."""
|
||||
result = math_ops.LOG10(CLOSE_POSITIVE)
|
||||
expected = np.log10(CLOSE_POSITIVE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_sqrt_exact_match(self):
|
||||
"""SQRT should match np.sqrt exactly."""
|
||||
result = math_ops.SQRT(CLOSE_POSITIVE)
|
||||
expected = np.sqrt(CLOSE_POSITIVE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_ceil_exact_match(self):
|
||||
"""CEIL should match np.ceil exactly."""
|
||||
result = math_ops.CEIL(CLOSE)
|
||||
expected = np.ceil(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_floor_exact_match(self):
|
||||
"""FLOOR should match np.floor exactly."""
|
||||
result = math_ops.FLOOR(CLOSE)
|
||||
expected = np.floor(CLOSE)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary Operator Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBinaryOps:
|
||||
"""Test binary operators against NumPy equivalents.
|
||||
|
||||
Expected tolerance: atol=1e-14 (direct numpy calls)
|
||||
"""
|
||||
|
||||
def test_add_exact_match(self):
|
||||
"""ADD should match np.add exactly."""
|
||||
other = RNG.standard_normal(N)
|
||||
result = math_ops.ADD(CLOSE, other)
|
||||
expected = np.add(CLOSE, other)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_sub_exact_match(self):
|
||||
"""SUB should match np.subtract exactly."""
|
||||
other = RNG.standard_normal(N)
|
||||
result = math_ops.SUB(CLOSE, other)
|
||||
expected = np.subtract(CLOSE, other)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_mult_exact_match(self):
|
||||
"""MULT should match np.multiply exactly."""
|
||||
other = RNG.standard_normal(N)
|
||||
result = math_ops.MULT(CLOSE, other)
|
||||
expected = np.multiply(CLOSE, other)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
def test_div_exact_match(self):
|
||||
"""DIV should match np.divide exactly."""
|
||||
other = RNG.uniform(0.5, 2.0, N) # Avoid division by zero
|
||||
result = math_ops.DIV(CLOSE, other)
|
||||
expected = np.divide(CLOSE, other)
|
||||
assert np.allclose(result, expected, atol=1e-14)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rolling Operator Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRollingOps:
|
||||
"""Test rolling operators against pandas equivalents.
|
||||
|
||||
Expected tolerance: atol=1e-12 (float sum reordering)
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("period", [5, 10, 20, 30])
|
||||
def test_sum_matches_pandas_rolling(self, period):
|
||||
"""SUM should match pd.Series.rolling(p).sum()."""
|
||||
result = math_ops.SUM(CLOSE, timeperiod=period)
|
||||
expected = pd.Series(CLOSE).rolling(period).sum().to_numpy()
|
||||
|
||||
# Check NaN positions match
|
||||
assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected))
|
||||
|
||||
# Check values match where both are finite
|
||||
mask = ~np.isnan(result) & ~np.isnan(expected)
|
||||
assert np.allclose(result[mask], expected[mask], atol=1e-12)
|
||||
|
||||
@pytest.mark.parametrize("period", [5, 10, 20, 30])
|
||||
def test_max_matches_pandas_rolling(self, period):
|
||||
"""MAX should match pd.Series.rolling(p).max()."""
|
||||
result = math_ops.MAX(CLOSE, timeperiod=period)
|
||||
expected = pd.Series(CLOSE).rolling(period).max().to_numpy()
|
||||
|
||||
# Check NaN positions match
|
||||
assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected))
|
||||
|
||||
# Check values match where both are finite
|
||||
mask = ~np.isnan(result) & ~np.isnan(expected)
|
||||
assert np.allclose(result[mask], expected[mask], atol=1e-12)
|
||||
|
||||
@pytest.mark.parametrize("period", [5, 10, 20, 30])
|
||||
def test_min_matches_pandas_rolling(self, period):
|
||||
"""MIN should match pd.Series.rolling(p).min()."""
|
||||
result = math_ops.MIN(CLOSE, timeperiod=period)
|
||||
expected = pd.Series(CLOSE).rolling(period).min().to_numpy()
|
||||
|
||||
# Check NaN positions match
|
||||
assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected))
|
||||
|
||||
# Check values match where both are finite
|
||||
mask = ~np.isnan(result) & ~np.isnan(expected)
|
||||
assert np.allclose(result[mask], expected[mask], atol=1e-12)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index Operator Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIndexOps:
|
||||
"""Test MAXINDEX and MININDEX point to correct argmax/argmin in window."""
|
||||
|
||||
@pytest.mark.parametrize("period", [5, 10, 20])
|
||||
def test_maxindex_points_to_max(self, period):
|
||||
"""MAXINDEX should point to the index of the rolling maximum."""
|
||||
result_idx = math_ops.MAXINDEX(CLOSE, timeperiod=period)
|
||||
result_max = math_ops.MAX(CLOSE, timeperiod=period)
|
||||
|
||||
# Skip warmup period
|
||||
for i in range(period - 1, N):
|
||||
idx = result_idx[i]
|
||||
max_val = result_max[i]
|
||||
|
||||
# During warmup, index is -1
|
||||
if idx == -1:
|
||||
assert np.isnan(max_val)
|
||||
else:
|
||||
# Index should point to the actual maximum in the window
|
||||
assert CLOSE[idx] == max_val, (
|
||||
f"At position {i}, MAXINDEX={idx} but CLOSE[{idx}]={CLOSE[idx]} "
|
||||
f"!= MAX={max_val}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("period", [5, 10, 20])
|
||||
def test_minindex_points_to_min(self, period):
|
||||
"""MININDEX should point to the index of the rolling minimum."""
|
||||
result_idx = math_ops.MININDEX(CLOSE, timeperiod=period)
|
||||
result_min = math_ops.MIN(CLOSE, timeperiod=period)
|
||||
|
||||
# Skip warmup period
|
||||
for i in range(period - 1, N):
|
||||
idx = result_idx[i]
|
||||
min_val = result_min[i]
|
||||
|
||||
# During warmup, index is -1
|
||||
if idx == -1:
|
||||
assert np.isnan(min_val)
|
||||
else:
|
||||
# Index should point to the actual minimum in the window
|
||||
assert CLOSE[idx] == min_val, (
|
||||
f"At position {i}, MININDEX={idx} but CLOSE[{idx}]={CLOSE[idx]} "
|
||||
f"!= MIN={min_val}"
|
||||
)
|
||||
|
||||
def test_maxindex_warmup_returns_minus_one(self):
|
||||
"""MAXINDEX should return -1 during warmup period."""
|
||||
period = 10
|
||||
result = math_ops.MAXINDEX(CLOSE, timeperiod=period)
|
||||
|
||||
# First period-1 values should be -1
|
||||
for i in range(period - 1):
|
||||
assert result[i] == -1, f"Expected -1 at index {i}, got {result[i]}"
|
||||
|
||||
def test_minindex_warmup_returns_minus_one(self):
|
||||
"""MININDEX should return -1 during warmup period."""
|
||||
period = 10
|
||||
result = math_ops.MININDEX(CLOSE, timeperiod=period)
|
||||
|
||||
# First period-1 values should be -1
|
||||
for i in range(period - 1):
|
||||
assert result[i] == -1, f"Expected -1 at index {i}, got {result[i]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge Case Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and document behavior.
|
||||
|
||||
Documents behavior for:
|
||||
- LN(negative) → NaN
|
||||
- SQRT(negative) → NaN
|
||||
- DIV(by zero) → inf
|
||||
- ACOS(>1) → NaN
|
||||
"""
|
||||
|
||||
def test_ln_negative_returns_nan(self):
|
||||
"""LN of negative values should return NaN."""
|
||||
negative = np.array([-1.0, -2.0, -3.0])
|
||||
result = math_ops.LN(negative)
|
||||
assert np.all(np.isnan(result)), "LN(negative) should return NaN"
|
||||
|
||||
def test_sqrt_negative_returns_nan(self):
|
||||
"""SQRT of negative values should return NaN."""
|
||||
negative = np.array([-1.0, -4.0, -9.0])
|
||||
result = math_ops.SQRT(negative)
|
||||
assert np.all(np.isnan(result)), "SQRT(negative) should return NaN"
|
||||
|
||||
def test_div_by_zero_returns_inf(self):
|
||||
"""DIV by zero should return inf (NumPy behavior)."""
|
||||
numerator = np.array([1.0, 2.0, 3.0])
|
||||
denominator = np.array([0.0, 0.0, 0.0])
|
||||
result = math_ops.DIV(numerator, denominator)
|
||||
assert np.all(np.isinf(result)), "DIV(by zero) should return inf"
|
||||
|
||||
def test_acos_out_of_range_returns_nan(self):
|
||||
"""ACOS of values outside [-1, 1] should return NaN."""
|
||||
out_of_range = np.array([1.5, 2.0, -1.5])
|
||||
result = math_ops.ACOS(out_of_range)
|
||||
assert np.all(np.isnan(result)), "ACOS(>1 or <-1) should return NaN"
|
||||
|
||||
def test_asin_out_of_range_returns_nan(self):
|
||||
"""ASIN of values outside [-1, 1] should return NaN."""
|
||||
out_of_range = np.array([1.5, 2.0, -1.5])
|
||||
result = math_ops.ASIN(out_of_range)
|
||||
assert np.all(np.isnan(result)), "ASIN(>1 or <-1) should return NaN"
|
||||
|
||||
def test_log10_zero_returns_negative_inf(self):
|
||||
"""LOG10(0) should return -inf."""
|
||||
zero = np.array([0.0])
|
||||
result = math_ops.LOG10(zero)
|
||||
assert np.isinf(result[0]) and result[0] < 0, "LOG10(0) should return -inf"
|
||||
|
||||
def test_ln_zero_returns_negative_inf(self):
|
||||
"""LN(0) should return -inf."""
|
||||
zero = np.array([0.0])
|
||||
result = math_ops.LN(zero)
|
||||
assert np.isinf(result[0]) and result[0] < 0, "LN(0) should return -inf"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Property-based tests (Hypothesis) for ferro-ta."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta import BBANDS, CDLDOJI, EMA, RSI, SMA
|
||||
|
||||
try:
|
||||
from hypothesis import given, settings
|
||||
from hypothesis.strategies import floats, integers, lists
|
||||
|
||||
HAS_HYPOTHESIS = True
|
||||
except ImportError:
|
||||
HAS_HYPOTHESIS = False
|
||||
|
||||
if HAS_HYPOTHESIS:
|
||||
# Strategy: finite floats, reasonable length
|
||||
finite_floats = floats(
|
||||
min_value=1e-6, max_value=1e6, allow_nan=False, allow_infinity=False
|
||||
)
|
||||
price_arrays = lists(finite_floats, min_size=2, max_size=500).map(np.array)
|
||||
periods = integers(min_value=1, max_value=100)
|
||||
|
||||
@given(price_arrays, periods)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_sma_output_length_matches_input(close, timeperiod):
|
||||
if len(close) < timeperiod:
|
||||
timeperiod = min(timeperiod, len(close))
|
||||
if timeperiod < 1:
|
||||
timeperiod = 1
|
||||
result = SMA(close, timeperiod=timeperiod)
|
||||
assert len(result) == len(close)
|
||||
|
||||
@given(price_arrays, periods)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_ema_output_length_matches_input(close, timeperiod):
|
||||
if len(close) < timeperiod:
|
||||
timeperiod = min(timeperiod, len(close))
|
||||
if timeperiod < 1:
|
||||
timeperiod = 1
|
||||
result = EMA(close, timeperiod=timeperiod)
|
||||
assert len(result) == len(close)
|
||||
|
||||
@given(price_arrays, periods)
|
||||
@settings(max_examples=50, deadline=5000)
|
||||
def test_rsi_output_length_matches_input(close, timeperiod):
|
||||
if len(close) < timeperiod:
|
||||
timeperiod = min(timeperiod, len(close))
|
||||
if timeperiod < 1:
|
||||
timeperiod = 1
|
||||
result = RSI(close, timeperiod=timeperiod)
|
||||
assert len(result) == len(close)
|
||||
|
||||
@given(price_arrays, periods)
|
||||
@settings(max_examples=30, deadline=5000)
|
||||
def test_bbands_three_outputs_same_length(close, timeperiod):
|
||||
if len(close) < timeperiod:
|
||||
timeperiod = min(timeperiod, len(close))
|
||||
if timeperiod < 1:
|
||||
timeperiod = 1
|
||||
upper, middle, lower = BBANDS(close, timeperiod=timeperiod)
|
||||
assert len(upper) == len(close)
|
||||
assert len(middle) == len(close)
|
||||
assert len(lower) == len(close)
|
||||
|
||||
@given(
|
||||
lists(finite_floats, min_size=3, max_size=100).map(np.array),
|
||||
lists(finite_floats, min_size=3, max_size=100).map(np.array),
|
||||
lists(finite_floats, min_size=3, max_size=100).map(np.array),
|
||||
lists(finite_floats, min_size=3, max_size=100).map(np.array),
|
||||
)
|
||||
@settings(max_examples=20, deadline=5000)
|
||||
def test_cdl_pattern_output_values_in_set(open_, high, low, close):
|
||||
n = min(len(open_), len(high), len(low), len(close))
|
||||
open_ = open_[:n]
|
||||
high = high[:n]
|
||||
low = low[:n]
|
||||
close = close[:n]
|
||||
result = CDLDOJI(open_, high, low, close)
|
||||
assert len(result) == n
|
||||
assert all(v in (-100, 0, 100) for v in result)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_HYPOTHESIS, reason="hypothesis not installed")
|
||||
class TestPropertyBased:
|
||||
"""Placeholder for running property-based tests as a class."""
|
||||
|
||||
def test_import(self):
|
||||
assert HAS_HYPOTHESIS
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
"""Tests for validation and error handling."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta import (
|
||||
ATR,
|
||||
BBANDS,
|
||||
CDLDOJI,
|
||||
MACD,
|
||||
RSI,
|
||||
SMA,
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
)
|
||||
from ferro_ta.core.exceptions import check_min_length, check_timeperiod
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invalid timeperiod / period parameters → FerroTAValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInvalidTimeperiod:
|
||||
"""Invalid period parameters must raise FerroTAValueError."""
|
||||
|
||||
def test_sma_timeperiod_zero(self):
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0)
|
||||
|
||||
def test_sma_timeperiod_negative(self):
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
SMA(np.array([1.0, 2.0, 3.0]), timeperiod=-1)
|
||||
|
||||
def test_rsi_timeperiod_zero(self):
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
RSI(np.array([1.0, 2.0, 3.0]), timeperiod=0)
|
||||
|
||||
def test_macd_fast_slow_periods(self):
|
||||
close = np.array([1.0, 2.0, 3.0, 4.0, 5.0] * 10)
|
||||
with pytest.raises(FerroTAValueError):
|
||||
MACD(close, fastperiod=26, slowperiod=12)
|
||||
|
||||
def test_bbands_timeperiod_zero(self):
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
BBANDS(np.array([1.0, 2.0, 3.0]), timeperiod=0)
|
||||
|
||||
def test_atr_timeperiod_zero(self):
|
||||
h = np.array([1.0, 2.0, 3.0])
|
||||
low = np.array([0.5, 1.5, 2.5])
|
||||
c = np.array([0.8, 1.8, 2.8])
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
ATR(h, low, c, timeperiod=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mismatched array lengths → FerroTAInputError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMismatchedLengths:
|
||||
"""Mismatched OHLCV lengths must raise FerroTAInputError."""
|
||||
|
||||
def test_atr_mismatched_lengths(self):
|
||||
h = np.array([1.0, 2.0, 3.0])
|
||||
low = np.array([0.5, 1.5])
|
||||
c = np.array([0.8, 1.8, 2.8])
|
||||
with pytest.raises(FerroTAInputError, match="same length"):
|
||||
ATR(h, low, c, timeperiod=2)
|
||||
|
||||
def test_cdl_pattern_mismatched_lengths(self):
|
||||
open_ = np.array([1.0, 2.0, 3.0])
|
||||
high = np.array([1.1, 2.1])
|
||||
low = np.array([0.9, 1.9, 2.9])
|
||||
close = np.array([1.05, 2.05, 3.05])
|
||||
with pytest.raises(FerroTAInputError, match="same length"):
|
||||
CDLDOJI(open_, high, low, close)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty and short arrays (defined behaviour or clear exception)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyAndShortArrays:
|
||||
"""Empty or too-short arrays have defined behaviour or raise."""
|
||||
|
||||
def test_sma_empty_array(self):
|
||||
# Empty array: _to_f64 returns shape (0,); Rust may return empty or raise.
|
||||
arr = np.array([], dtype=np.float64)
|
||||
result = SMA(arr, timeperiod=1)
|
||||
assert result.shape == (0,)
|
||||
|
||||
def test_sma_single_element_timeperiod_one(self):
|
||||
arr = np.array([1.0])
|
||||
result = SMA(arr, timeperiod=1)
|
||||
assert len(result) == 1
|
||||
assert result[0] == 1.0
|
||||
|
||||
def test_sma_short_array_timeperiod_larger_than_length(self):
|
||||
# len=3, timeperiod=5 → output is all NaN for warmup
|
||||
arr = np.array([1.0, 2.0, 3.0])
|
||||
result = SMA(arr, timeperiod=5)
|
||||
assert len(result) == 3
|
||||
assert np.all(np.isnan(result))
|
||||
|
||||
def test_rsi_all_nan_input(self):
|
||||
# All-NaN input: output is all NaN (propagation)
|
||||
arr = np.array([np.nan, np.nan, np.nan, np.nan, np.nan])
|
||||
result = RSI(arr, timeperiod=2)
|
||||
assert len(result) == 5
|
||||
assert np.all(np.isnan(result))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers (check_timeperiod, check_min_length)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidationHelpers:
|
||||
"""Exported validation helpers behave as documented."""
|
||||
|
||||
def test_check_timeperiod_ok(self):
|
||||
check_timeperiod(5)
|
||||
check_timeperiod(1)
|
||||
|
||||
def test_check_timeperiod_raises(self):
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
check_timeperiod(0)
|
||||
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"):
|
||||
check_timeperiod(-1)
|
||||
|
||||
def test_check_min_length_ok(self):
|
||||
check_min_length(np.array([1.0, 2.0, 3.0]), 2)
|
||||
check_min_length([1, 2, 3], 3)
|
||||
|
||||
def test_check_min_length_raises(self):
|
||||
with pytest.raises(FerroTAInputError, match="at least 3 elements"):
|
||||
check_min_length(np.array([1.0, 2.0]), 3, name="input")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception inheritance (ValueError still works)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExceptionInheritance:
|
||||
"""FerroTAValueError/FerroTAInputError are ValueErrors for backward compatibility."""
|
||||
|
||||
def test_catch_value_error(self):
|
||||
with pytest.raises(ValueError, match="timeperiod must be >= 1"):
|
||||
SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0)
|
||||
|
||||
def test_catch_ferro_ta_value_error(self):
|
||||
with pytest.raises(FerroTAValueError):
|
||||
SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0)
|
||||
Reference in New Issue
Block a user