扩展指标
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""Unit tests for ferro_ta.indicators.cycle"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ferro_ta.indicators.cycle import (
|
||||
HT_DCPERIOD,
|
||||
HT_DCPHASE,
|
||||
HT_PHASOR,
|
||||
HT_SINE,
|
||||
HT_TRENDLINE,
|
||||
HT_TRENDMODE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures — cycle indicators need at least ~64 bars for valid output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
N = 200
|
||||
t = np.linspace(0, 10 * np.pi, N)
|
||||
SINE_CLOSE = 100 + 10 * np.sin(t) # clean sine wave
|
||||
|
||||
|
||||
def _warmup_end(arr):
|
||||
"""Return index of first non-NaN value (or N if all NaN)."""
|
||||
valid = np.where(~np.isnan(arr.astype(float)))[0]
|
||||
return valid[0] if len(valid) else N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HT_DCPERIOD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_DCPERIOD:
|
||||
def test_length(self):
|
||||
result = HT_DCPERIOD(SINE_CLOSE)
|
||||
assert len(result) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = HT_DCPERIOD(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
assert w > 0
|
||||
assert np.all(np.isnan(result[:w]))
|
||||
|
||||
def test_valid_finite(self):
|
||||
result = HT_DCPERIOD(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
assert np.all(np.isfinite(result[w:]))
|
||||
|
||||
def test_sine_period_reasonable(self):
|
||||
# Our sine has period = 2*pi in t; with N=200 and t in [0,10*pi]
|
||||
# the true period in samples = 200 / (10*pi / (2*pi)) = 200/5 = 40
|
||||
result = HT_DCPERIOD(SINE_CLOSE)
|
||||
valid = result[~np.isnan(result)]
|
||||
# HT_DCPERIOD should detect a period in a reasonable range [6, 100]
|
||||
assert np.any((valid > 6) & (valid < 100))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HT_DCPHASE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_DCPHASE:
|
||||
def test_length(self):
|
||||
assert len(HT_DCPHASE(SINE_CLOSE)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = HT_DCPHASE(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
assert w > 0
|
||||
|
||||
def test_valid_finite(self):
|
||||
result = HT_DCPHASE(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
assert np.all(np.isfinite(result[w:]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HT_PHASOR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_PHASOR:
|
||||
def test_returns_two_arrays(self):
|
||||
result = HT_PHASOR(SINE_CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_length(self):
|
||||
inphase, quadrature = HT_PHASOR(SINE_CLOSE)
|
||||
assert len(inphase) == len(quadrature) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
inphase, quadrature = HT_PHASOR(SINE_CLOSE)
|
||||
w = _warmup_end(inphase)
|
||||
assert w > 0
|
||||
|
||||
def test_valid_finite(self):
|
||||
inphase, quadrature = HT_PHASOR(SINE_CLOSE)
|
||||
wi = _warmup_end(inphase)
|
||||
wq = _warmup_end(quadrature)
|
||||
assert np.all(np.isfinite(inphase[wi:]))
|
||||
assert np.all(np.isfinite(quadrature[wq:]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HT_SINE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_SINE:
|
||||
def test_returns_two_arrays(self):
|
||||
result = HT_SINE(SINE_CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_length(self):
|
||||
sine, leadsine = HT_SINE(SINE_CLOSE)
|
||||
assert len(sine) == len(leadsine) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
sine, leadsine = HT_SINE(SINE_CLOSE)
|
||||
w = _warmup_end(sine)
|
||||
assert w > 0
|
||||
|
||||
def test_valid_finite(self):
|
||||
sine, leadsine = HT_SINE(SINE_CLOSE)
|
||||
ws = _warmup_end(sine)
|
||||
wl = _warmup_end(leadsine)
|
||||
assert np.all(np.isfinite(sine[ws:]))
|
||||
assert np.all(np.isfinite(leadsine[wl:]))
|
||||
|
||||
def test_values_in_sine_range(self):
|
||||
# Sine values should be in [-1, 1] roughly
|
||||
sine, leadsine = HT_SINE(SINE_CLOSE)
|
||||
valid = sine[~np.isnan(sine)]
|
||||
assert np.all(valid >= -1.5) and np.all(valid <= 1.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HT_TRENDLINE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_TRENDLINE:
|
||||
def test_length(self):
|
||||
assert len(HT_TRENDLINE(SINE_CLOSE)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = HT_TRENDLINE(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
assert w > 0
|
||||
|
||||
def test_valid_finite(self):
|
||||
result = HT_TRENDLINE(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
assert np.all(np.isfinite(result[w:]))
|
||||
|
||||
def test_smooth_trendline(self):
|
||||
# Trendline should be smoother than raw close
|
||||
result = HT_TRENDLINE(SINE_CLOSE)
|
||||
w = _warmup_end(result)
|
||||
raw_std = np.std(np.diff(SINE_CLOSE[w:]))
|
||||
trend_std = np.std(np.diff(result[w:]))
|
||||
assert trend_std < raw_std
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HT_TRENDMODE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHT_TRENDMODE:
|
||||
def test_length(self):
|
||||
assert len(HT_TRENDMODE(SINE_CLOSE)) == N
|
||||
|
||||
def test_values_binary(self):
|
||||
result = HT_TRENDMODE(SINE_CLOSE)
|
||||
assert np.all(np.isin(result, [0, 1]))
|
||||
|
||||
def test_nan_warmup_as_zero(self):
|
||||
# HT_TRENDMODE returns integers (no NaN); warmup bars should be 0
|
||||
result = HT_TRENDMODE(SINE_CLOSE)
|
||||
assert np.all(np.isfinite(result.astype(float)))
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Unit tests for ferro_ta.indicators.extended"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ferro_ta.indicators.extended import (
|
||||
CHANDELIER_EXIT,
|
||||
CHOPPINESS_INDEX,
|
||||
DONCHIAN,
|
||||
HULL_MA,
|
||||
ICHIMOKU,
|
||||
KELTNER_CHANNELS,
|
||||
PIVOT_POINTS,
|
||||
SUPERTREND,
|
||||
VWAP,
|
||||
VWMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,313 @@
|
||||
"""Unit tests for ferro_ta.indicators.math_ops"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ferro_ta.indicators.math_ops import (
|
||||
ACOS,
|
||||
ADD,
|
||||
ASIN,
|
||||
ATAN,
|
||||
CEIL,
|
||||
COS,
|
||||
COSH,
|
||||
DIV,
|
||||
EXP,
|
||||
FLOOR,
|
||||
LN,
|
||||
LOG10,
|
||||
MAX,
|
||||
MAXINDEX,
|
||||
MIN,
|
||||
MININDEX,
|
||||
MULT,
|
||||
SIN,
|
||||
SINH,
|
||||
SQRT,
|
||||
SUB,
|
||||
SUM,
|
||||
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,588 @@
|
||||
"""Unit tests for ferro_ta.indicators.momentum"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ferro_ta.indicators.momentum import (
|
||||
ADX,
|
||||
ADXR,
|
||||
APO,
|
||||
AROON,
|
||||
AROONOSC,
|
||||
BOP,
|
||||
CCI,
|
||||
CMO,
|
||||
DX,
|
||||
MFI,
|
||||
MINUS_DI,
|
||||
MINUS_DM,
|
||||
MOM,
|
||||
PLUS_DI,
|
||||
PLUS_DM,
|
||||
PPO,
|
||||
ROC,
|
||||
ROCP,
|
||||
ROCR,
|
||||
ROCR100,
|
||||
RSI,
|
||||
STOCH,
|
||||
STOCHF,
|
||||
STOCHRSI,
|
||||
TRIX,
|
||||
ULTOSC,
|
||||
WILLR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(7)
|
||||
N = 100
|
||||
_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N))
|
||||
_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N))
|
||||
_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N))
|
||||
_OPEN = _CLOSE + RNG.normal(0, 0.1, N)
|
||||
_VOL = RNG.uniform(1000, 5000, N)
|
||||
|
||||
SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
SMALL5_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
SMALL5_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
SMALL5_O = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
SMALL5_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRSI:
|
||||
def test_nan_warmup(self):
|
||||
result = RSI(_CLOSE, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:14]))
|
||||
|
||||
def test_range(self):
|
||||
result = RSI(_CLOSE, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
assert len(RSI(_CLOSE, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STOCH
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTOCH:
|
||||
def test_returns_two_arrays(self):
|
||||
result = STOCH(_HIGH, _LOW, _CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_range(self):
|
||||
slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE)
|
||||
for arr in [slowk, slowd]:
|
||||
valid = arr[~np.isnan(arr)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE)
|
||||
assert len(slowk) == len(slowd) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STOCHF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTOCHF:
|
||||
def test_returns_two_arrays(self):
|
||||
result = STOCHF(_HIGH, _LOW, _CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_fastk_range(self):
|
||||
fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE, fastk_period=5, fastd_period=3)
|
||||
valid = fastk[~np.isnan(fastk)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_known_values(self):
|
||||
# With identical OHLC, fast %K = 100 * (C - min_low) / (max_high - min_low)
|
||||
# On our SMALL5 data the range is constant so all = 2/6 * 100 ≈ 66.67
|
||||
h5 = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
l5 = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
c5 = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
fastk, fastd = STOCHF(h5, l5, c5, fastk_period=3, fastd_period=2)
|
||||
valid_k = fastk[~np.isnan(fastk)]
|
||||
assert np.all(valid_k >= 0) and np.all(valid_k <= 100)
|
||||
|
||||
def test_length(self):
|
||||
fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE)
|
||||
assert len(fastk) == len(fastd) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STOCHRSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSTOCHRSI:
|
||||
def test_returns_two_arrays(self):
|
||||
result = STOCHRSI(_CLOSE)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_range(self):
|
||||
fastk, fastd = STOCHRSI(_CLOSE, timeperiod=14)
|
||||
for arr in [fastk, fastd]:
|
||||
valid = arr[~np.isnan(arr)]
|
||||
assert np.all(valid >= -1e-10) and np.all(valid <= 100 + 1e-10)
|
||||
|
||||
def test_length(self):
|
||||
fastk, fastd = STOCHRSI(_CLOSE)
|
||||
assert len(fastk) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADX:
|
||||
def test_nan_warmup(self):
|
||||
result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:27]))
|
||||
|
||||
def test_range(self):
|
||||
result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
assert len(ADX(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADXR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADXR:
|
||||
def test_length(self):
|
||||
assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
def test_range(self):
|
||||
result = ADXR(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CCI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCCI:
|
||||
def test_known_constant_mean_dev(self):
|
||||
# Constant typical price → CCI = 0 after warmup
|
||||
c5 = np.full(10, 12.0)
|
||||
h5 = np.full(10, 13.0)
|
||||
l5 = np.full(10, 11.0)
|
||||
result = CCI(h5, l5, c5, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
np.testing.assert_allclose(valid, 0.0, atol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(CCI(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = CCI(_HIGH, _LOW, _CLOSE, timeperiod=14)
|
||||
assert np.all(np.isnan(result[:13]))
|
||||
|
||||
def test_simple_rising(self):
|
||||
h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
l = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
c = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
|
||||
result = CCI(h, l, c, 3)
|
||||
valid = result[~np.isnan(result)]
|
||||
np.testing.assert_allclose(valid, 100.0, atol=1e-8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WILLR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWILLR:
|
||||
def test_range(self):
|
||||
result = WILLR(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= -100) and np.all(valid <= 0)
|
||||
|
||||
def test_length(self):
|
||||
assert len(WILLR(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AROON
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAROON:
|
||||
def test_returns_two_arrays(self):
|
||||
result = AROON(_HIGH, _LOW, 14)
|
||||
assert isinstance(result, tuple) and len(result) == 2
|
||||
|
||||
def test_range(self):
|
||||
aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
|
||||
for arr in [aroon_down, aroon_up]:
|
||||
valid = arr[~np.isnan(arr)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
|
||||
assert len(aroon_down) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AROONOSC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAROONOSC:
|
||||
def test_known_values(self):
|
||||
h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
|
||||
l = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
|
||||
result = AROONOSC(h, l, timeperiod=2)
|
||||
valid = result[~np.isnan(result)]
|
||||
# Monotone rising high/low → aroon_up = 100, aroon_down = 0 → osc = 100
|
||||
np.testing.assert_allclose(valid, 100.0, atol=1e-10)
|
||||
|
||||
def test_equals_aroon_diff(self):
|
||||
aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
|
||||
aroonosc = AROONOSC(_HIGH, _LOW, 14)
|
||||
valid = ~np.isnan(aroon_up) & ~np.isnan(aroon_down) & ~np.isnan(aroonosc)
|
||||
np.testing.assert_allclose(
|
||||
aroonosc[valid],
|
||||
aroon_up[valid] - aroon_down[valid],
|
||||
atol=1e-10,
|
||||
)
|
||||
|
||||
def test_length(self):
|
||||
assert len(AROONOSC(_HIGH, _LOW, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MFI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMFI:
|
||||
def test_range(self):
|
||||
result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
|
||||
assert np.all(np.isnan(result[:14]))
|
||||
|
||||
def test_constant_price_is_50(self):
|
||||
# When money flow is neither positive nor negative → MFI should be near 50
|
||||
# Use alternating tiny moves around constant so no clear direction
|
||||
c = np.full(20, 100.0)
|
||||
h = np.full(20, 101.0)
|
||||
l = np.full(20, 99.0)
|
||||
v = np.full(20, 1000.0)
|
||||
result = MFI(h, l, c, v, 5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert len(valid) > 0 # just ensure it runs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MOM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMOM:
|
||||
def test_known_values(self):
|
||||
result = MOM(SMALL5, timeperiod=2)
|
||||
assert np.isnan(result[0]) and np.isnan(result[1])
|
||||
np.testing.assert_allclose(result[2], 2.0, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[3], 2.0, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(MOM(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROC:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
result = ROC(arr, 2)
|
||||
# ROC = ((close - close[n]) / close[n]) * 100
|
||||
np.testing.assert_allclose(result[2], (12 - 10) / 10 * 100, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(ROC(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROCP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCP:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
result = ROCP(arr, 2)
|
||||
# ROCP = (close - close[n]) / close[n]
|
||||
np.testing.assert_allclose(result[2], (12 - 10) / 10, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(ROCP(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROCR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCR:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
result = ROCR(arr, 2)
|
||||
# ROCR = close / close[n]
|
||||
np.testing.assert_allclose(result[2], 12 / 10, rtol=1e-10)
|
||||
np.testing.assert_allclose(result[4], 14 / 12, rtol=1e-10)
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = ROCR(_CLOSE, 10)
|
||||
assert np.all(np.isnan(result[:10]))
|
||||
|
||||
def test_length(self):
|
||||
assert len(ROCR(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROCR100
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestROCR100:
|
||||
def test_known_values(self):
|
||||
arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
|
||||
result = ROCR100(arr, 2)
|
||||
# ROCR100 = (close / close[n]) * 100
|
||||
np.testing.assert_allclose(result[2], 12 / 10 * 100, rtol=1e-10)
|
||||
|
||||
def test_relation_to_rocr(self):
|
||||
rocr = ROCR(_CLOSE, 5)
|
||||
rocr100 = ROCR100(_CLOSE, 5)
|
||||
valid = ~np.isnan(rocr)
|
||||
np.testing.assert_allclose(rocr100[valid], rocr[valid] * 100, rtol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
assert len(ROCR100(_CLOSE, 10)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CMO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCMO:
|
||||
def test_range(self):
|
||||
result = CMO(_CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= -100) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
assert len(CMO(_CLOSE, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDX:
|
||||
def test_range(self):
|
||||
result = DX(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
assert len(DX(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MINUS_DI / MINUS_DM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMINUS:
|
||||
def test_minus_di_range(self):
|
||||
result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0)
|
||||
|
||||
def test_minus_dm_range(self):
|
||||
result = MINUS_DM(_HIGH, _LOW, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0)
|
||||
|
||||
def test_lengths(self):
|
||||
assert len(MINUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
assert len(MINUS_DM(_HIGH, _LOW, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PLUS_DI / PLUS_DM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPLUS:
|
||||
def test_plus_di_range(self):
|
||||
result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0)
|
||||
|
||||
def test_plus_dm_range(self):
|
||||
result = PLUS_DM(_HIGH, _LOW, 14)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0)
|
||||
|
||||
def test_lengths(self):
|
||||
assert len(PLUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N
|
||||
assert len(PLUS_DM(_HIGH, _LOW, 14)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PPO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPPO:
|
||||
def test_returns_three_arrays(self):
|
||||
result = PPO(_CLOSE, fastperiod=12, slowperiod=26)
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
|
||||
def test_histogram_is_diff(self):
|
||||
ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26)
|
||||
valid = ~np.isnan(ppo) & ~np.isnan(signal)
|
||||
np.testing.assert_allclose(hist[valid], ppo[valid] - signal[valid], atol=1e-10)
|
||||
|
||||
def test_length(self):
|
||||
ppo, signal, hist = PPO(_CLOSE)
|
||||
assert len(ppo) == len(signal) == len(hist) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26)
|
||||
assert np.any(np.isnan(ppo))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# APO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAPO:
|
||||
def test_known_direction(self):
|
||||
# Rising close → fast EMA > slow EMA → APO > 0 after warmup
|
||||
rising = np.linspace(1.0, 100.0, 60)
|
||||
result = APO(rising, fastperiod=5, slowperiod=10)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid > 0)
|
||||
|
||||
def test_length(self):
|
||||
assert len(APO(_CLOSE, 12, 26)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = APO(_CLOSE, 12, 26)
|
||||
assert np.any(np.isnan(result))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TRIX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTRIX:
|
||||
def test_length(self):
|
||||
assert len(TRIX(_CLOSE, 10)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = TRIX(_CLOSE, timeperiod=5)
|
||||
# TRIX warmup = 3*(tp-1) for triple EMA + 1 for diff
|
||||
assert np.all(np.isnan(result[:12]))
|
||||
|
||||
def test_finite_after_warmup(self):
|
||||
result = TRIX(_CLOSE, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(np.isfinite(valid))
|
||||
|
||||
def test_rising_series_positive(self):
|
||||
rising = np.linspace(1.0, 200.0, 100)
|
||||
result = TRIX(rising, timeperiod=5)
|
||||
valid = result[~np.isnan(result)]
|
||||
# On monotone rise, rate of change of triple EMA is positive
|
||||
assert np.all(valid > 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBOP:
|
||||
def test_known_values(self):
|
||||
o = np.array([10.0, 11.0])
|
||||
h = np.array([14.0, 15.0])
|
||||
l = np.array([8.0, 9.0])
|
||||
c = np.array([12.0, 13.0])
|
||||
# BOP = (close - open) / (high - low)
|
||||
result = BOP(o, h, l, c)
|
||||
np.testing.assert_allclose(result[0], (12 - 10) / (14 - 8), rtol=1e-10)
|
||||
np.testing.assert_allclose(result[1], (13 - 11) / (15 - 9), rtol=1e-10)
|
||||
|
||||
def test_bearish_is_negative(self):
|
||||
o = np.array([14.0, 14.0])
|
||||
h = np.array([15.0, 15.0])
|
||||
l = np.array([8.0, 8.0])
|
||||
c = np.array([10.0, 10.0])
|
||||
result = BOP(o, h, l, c)
|
||||
assert np.all(result < 0)
|
||||
|
||||
def test_range(self):
|
||||
# BOP = (close - open) / (high - low); can exceed [-1,1] with noisy data
|
||||
result = BOP(_OPEN, _HIGH, _LOW, _CLOSE)
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_length(self):
|
||||
assert len(BOP(_OPEN, _HIGH, _LOW, _CLOSE)) == N
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ULTOSC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestULTOSC:
|
||||
def test_range(self):
|
||||
result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
|
||||
valid = result[~np.isnan(result)]
|
||||
assert np.all(valid >= 0) and np.all(valid <= 100)
|
||||
|
||||
def test_length(self):
|
||||
assert len(ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)) == N
|
||||
|
||||
def test_nan_warmup(self):
|
||||
result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
|
||||
assert np.any(np.isnan(result))
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Unit tests for ferro_ta.indicators.overlap"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ferro_ta.indicators.overlap import (
|
||||
BBANDS,
|
||||
DEMA,
|
||||
EMA,
|
||||
KAMA,
|
||||
MA,
|
||||
MACD,
|
||||
MACDEXT,
|
||||
MACDFIX,
|
||||
MAMA,
|
||||
MAVP,
|
||||
MIDPOINT,
|
||||
MIDPRICE,
|
||||
SAR,
|
||||
SAREXT,
|
||||
SMA,
|
||||
T3,
|
||||
TEMA,
|
||||
TRIMA,
|
||||
WMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,260 @@
|
||||
"""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,113 @@
|
||||
"""Unit tests for ferro_ta.indicators.price_transform"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
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
|
||||
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,488 @@
|
||||
"""Unit tests for ferro_ta.indicators.statistic"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.statistic import (
|
||||
BATCH_DTW,
|
||||
BETA,
|
||||
CORREL,
|
||||
DTW,
|
||||
DTW_DISTANCE,
|
||||
LINEARREG,
|
||||
LINEARREG_ANGLE,
|
||||
LINEARREG_INTERCEPT,
|
||||
LINEARREG_SLOPE,
|
||||
STDDEV,
|
||||
TSF,
|
||||
VAR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
def _naive_linreg_window(window: np.ndarray) -> tuple[float, float]:
|
||||
x = np.arange(len(window), dtype=np.float64)
|
||||
sum_x = float(np.sum(x))
|
||||
sum_y = float(np.sum(window))
|
||||
sum_xy = float(np.sum(x * window))
|
||||
sum_x2 = float(np.sum(x * x))
|
||||
n = float(len(window))
|
||||
denom = n * sum_x2 - sum_x * sum_x
|
||||
slope = (n * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0
|
||||
intercept = (sum_y - slope * sum_x) / n
|
||||
return slope, intercept
|
||||
|
||||
|
||||
def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray:
|
||||
out = np.full(len(series), np.nan, dtype=np.float64)
|
||||
for end in range(timeperiod - 1, len(series)):
|
||||
slope, intercept = _naive_linreg_window(series[end + 1 - timeperiod : end + 1])
|
||||
out[end] = intercept + slope * x_value
|
||||
return out
|
||||
|
||||
|
||||
def _naive_correl(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray:
|
||||
out = np.full(len(x), np.nan, dtype=np.float64)
|
||||
for end in range(timeperiod - 1, len(x)):
|
||||
x_window = x[end + 1 - timeperiod : end + 1]
|
||||
y_window = y[end + 1 - timeperiod : end + 1]
|
||||
mean_x = float(np.sum(x_window)) / timeperiod
|
||||
mean_y = float(np.sum(y_window)) / timeperiod
|
||||
cov = float(np.sum((x_window - mean_x) * (y_window - mean_y)))
|
||||
std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2)))
|
||||
std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2)))
|
||||
denom = std_x * std_y
|
||||
out[end] = cov / denom if denom != 0.0 else np.nan
|
||||
return out
|
||||
|
||||
|
||||
def _naive_beta(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray:
|
||||
out = np.full(len(x), np.nan, dtype=np.float64)
|
||||
for end in range(timeperiod, len(x)):
|
||||
start = end - timeperiod
|
||||
rx = np.array(
|
||||
[
|
||||
x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan
|
||||
for idx in range(start, end)
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
ry = np.array(
|
||||
[
|
||||
y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan
|
||||
for idx in range(start, end)
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
mean_x = float(np.sum(rx)) / timeperiod
|
||||
mean_y = float(np.sum(ry)) / timeperiod
|
||||
cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / timeperiod
|
||||
var_x = float(np.sum((rx - mean_x) ** 2)) / timeperiod
|
||||
out[end] = cov / var_x if var_x != 0.0 else np.nan
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
def test_matches_naive_regression(self):
|
||||
expected = _naive_linearreg(_A, timeperiod=14, x_value=13.0)
|
||||
result = LINEARREG(_A, timeperiod=14)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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))
|
||||
|
||||
def test_matches_naive_beta(self):
|
||||
expected = _naive_beta(_A, _B, timeperiod=5)
|
||||
result = BETA(_A, _B, timeperiod=5)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
def test_matches_naive_correlation(self):
|
||||
expected = _naive_correl(_A, _B, timeperiod=10)
|
||||
result = CORREL(_A, _B, timeperiod=10)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
def test_matches_naive_tsf(self):
|
||||
expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0)
|
||||
result = TSF(_A, timeperiod=14)
|
||||
np.testing.assert_allclose(result, expected, equal_nan=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DTW — Dynamic Time Warping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
dtai = pytest.importorskip("dtaidistance", reason="dtaidistance not installed")
|
||||
|
||||
_DTW_RNG = np.random.default_rng(42)
|
||||
|
||||
|
||||
class TestDTW:
|
||||
# --- Validation against dtaidistance (SOTA reference) ---
|
||||
|
||||
def test_distance_matches_dtaidistance_random(self):
|
||||
"""Core correctness: our distance == dtaidistance on 20 random pairs."""
|
||||
for _ in range(20):
|
||||
n = int(_DTW_RNG.integers(5, 50))
|
||||
a = _DTW_RNG.random(n)
|
||||
b = _DTW_RNG.random(n)
|
||||
expected = dtai.dtw.distance(a, b)
|
||||
actual = DTW_DISTANCE(a, b)
|
||||
np.testing.assert_allclose(
|
||||
actual, expected, rtol=1e-9, err_msg=f"Mismatch on series length {n}"
|
||||
)
|
||||
|
||||
def test_distance_matches_dtaidistance_unequal_length(self):
|
||||
"""Handles unequal-length series correctly."""
|
||||
for _ in range(10):
|
||||
a = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
|
||||
b = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
|
||||
expected = dtai.dtw.distance(a, b)
|
||||
actual = DTW_DISTANCE(a, b)
|
||||
np.testing.assert_allclose(actual, expected, rtol=1e-9)
|
||||
|
||||
def test_path_distance_matches_dtaidistance(self):
|
||||
"""DTW() path variant: returned distance matches dtaidistance."""
|
||||
a = _DTW_RNG.random(20)
|
||||
b = _DTW_RNG.random(25)
|
||||
expected = dtai.dtw.distance(a, b)
|
||||
dist, _ = DTW(a, b)
|
||||
np.testing.assert_allclose(dist, expected, rtol=1e-9)
|
||||
|
||||
def test_path_matches_dtaidistance_warping_path(self):
|
||||
"""Warping path matches dtaidistance.dtw.warping_path() on same-length series."""
|
||||
for _ in range(10):
|
||||
n = int(_DTW_RNG.integers(5, 20))
|
||||
a = _DTW_RNG.random(n)
|
||||
b = _DTW_RNG.random(n)
|
||||
expected_path = dtai.dtw.warping_path(a, b)
|
||||
_, actual_path = DTW(a, b)
|
||||
actual_pairs = [tuple(int(x) for x in row) for row in actual_path]
|
||||
assert actual_pairs == expected_path, (
|
||||
f"Path mismatch for n={n}:\n ours={actual_pairs}\n dtai={expected_path}"
|
||||
)
|
||||
|
||||
def test_window_constrained_matches_dtaidistance(self):
|
||||
"""Sakoe-Chiba window matches dtaidistance window parameter."""
|
||||
a = _DTW_RNG.random(30)
|
||||
b = _DTW_RNG.random(30)
|
||||
for w in [3, 8, 15]:
|
||||
expected = dtai.dtw.distance(a, b, window=w)
|
||||
actual = DTW_DISTANCE(a, b, window=w)
|
||||
np.testing.assert_allclose(
|
||||
actual, expected, rtol=1e-9, err_msg=f"Mismatch at window={w}"
|
||||
)
|
||||
|
||||
def test_batch_matches_dtaidistance(self):
|
||||
"""BATCH_DTW matches calling dtaidistance per-row."""
|
||||
ref = _DTW_RNG.random(20)
|
||||
matrix = _DTW_RNG.random((8, 20))
|
||||
batch_result = BATCH_DTW(matrix, ref)
|
||||
for i in range(8):
|
||||
expected = dtai.dtw.distance(matrix[i], ref)
|
||||
np.testing.assert_allclose(
|
||||
batch_result[i],
|
||||
expected,
|
||||
rtol=1e-9,
|
||||
err_msg=f"Batch mismatch at row {i}",
|
||||
)
|
||||
|
||||
# --- Mathematical properties ---
|
||||
|
||||
def test_identical_distance_is_zero(self):
|
||||
a = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
dist, _ = DTW(a, a)
|
||||
assert dist == pytest.approx(0.0, abs=1e-10)
|
||||
|
||||
def test_symmetry(self):
|
||||
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
|
||||
assert DTW_DISTANCE(a, b) == pytest.approx(DTW_DISTANCE(b, a), rel=1e-10)
|
||||
|
||||
def test_triangle_inequality(self):
|
||||
a, b, c = _DTW_RNG.random(15), _DTW_RNG.random(15), _DTW_RNG.random(15)
|
||||
assert DTW_DISTANCE(a, c) <= DTW_DISTANCE(a, b) + DTW_DISTANCE(b, c) + 1e-9
|
||||
|
||||
# --- Known hardcoded values ---
|
||||
|
||||
def test_known_shifted_series(self):
|
||||
# [0,1,2] vs [1,2,3]: optimal path (0,0)→(1,0)→(2,1)→(2,2)
|
||||
# Squared costs: 1+0+0+1=2, sqrt(2). Verified against dtaidistance.
|
||||
a = np.array([0.0, 1.0, 2.0])
|
||||
b = np.array([1.0, 2.0, 3.0])
|
||||
np.testing.assert_allclose(DTW_DISTANCE(a, b), np.sqrt(2.0), rtol=1e-9)
|
||||
|
||||
def test_known_single_element(self):
|
||||
# sqrt((3-7)^2) = sqrt(16) = 4.0
|
||||
np.testing.assert_allclose(
|
||||
DTW_DISTANCE(np.array([3.0]), np.array([7.0])), 4.0, rtol=1e-9
|
||||
)
|
||||
|
||||
def test_known_constant_series(self):
|
||||
assert DTW_DISTANCE(np.full(10, 5.0), np.full(10, 5.0)) == pytest.approx(
|
||||
0.0, abs=1e-12
|
||||
)
|
||||
|
||||
# --- Path structural guarantees ---
|
||||
|
||||
def test_path_starts_at_origin(self):
|
||||
_, path = DTW(_DTW_RNG.random(10), _DTW_RNG.random(10))
|
||||
assert tuple(int(x) for x in path[0]) == (0, 0)
|
||||
|
||||
def test_path_ends_at_corner(self):
|
||||
_, path = DTW(_DTW_RNG.random(7), _DTW_RNG.random(9))
|
||||
assert tuple(int(x) for x in path[-1]) == (6, 8)
|
||||
|
||||
def test_path_is_monotone(self):
|
||||
_, path = DTW(_DTW_RNG.random(20), _DTW_RNG.random(20))
|
||||
for k in range(1, len(path)):
|
||||
assert path[k][0] >= path[k - 1][0]
|
||||
assert path[k][1] >= path[k - 1][1]
|
||||
|
||||
def test_path_steps_unit_size(self):
|
||||
_, path = DTW(_DTW_RNG.random(15), _DTW_RNG.random(12))
|
||||
for k in range(1, len(path)):
|
||||
di = int(path[k][0]) - int(path[k - 1][0])
|
||||
dj = int(path[k][1]) - int(path[k - 1][1])
|
||||
assert di in (0, 1) and dj in (0, 1)
|
||||
assert not (di == 0 and dj == 0)
|
||||
|
||||
# --- DTW_DISTANCE == DTW distance ---
|
||||
|
||||
def test_distance_only_matches_full(self):
|
||||
a, b = _DTW_RNG.random(25), _DTW_RNG.random(25)
|
||||
d_full, _ = DTW(a, b)
|
||||
np.testing.assert_allclose(DTW_DISTANCE(a, b), d_full, rtol=1e-10)
|
||||
|
||||
# --- Batch ---
|
||||
|
||||
def test_batch_single_row(self):
|
||||
ref = np.array([1.0, 2.0, 3.0])
|
||||
result = BATCH_DTW(np.array([[1.0, 2.0, 3.0]]), ref)
|
||||
assert result[0] == pytest.approx(0.0, abs=1e-10)
|
||||
|
||||
def test_batch_matches_single_calls(self):
|
||||
ref = _DTW_RNG.random(20)
|
||||
matrix = _DTW_RNG.random((8, 20))
|
||||
batch = BATCH_DTW(matrix, ref)
|
||||
for i in range(8):
|
||||
np.testing.assert_allclose(
|
||||
batch[i], DTW_DISTANCE(matrix[i], ref), rtol=1e-10
|
||||
)
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
def test_empty_series_raises(self):
|
||||
with pytest.raises((ValueError, Exception)):
|
||||
DTW(np.array([]), np.array([1.0, 2.0]))
|
||||
|
||||
def test_window_constrained_ge_unconstrained(self):
|
||||
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
|
||||
d_full = DTW_DISTANCE(a, b)
|
||||
d_narrow = DTW_DISTANCE(a, b, window=2)
|
||||
assert d_narrow >= d_full - 1e-9
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Unit tests for ferro_ta.indicators.volatility"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
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,118 @@
|
||||
"""Unit tests for ferro_ta.indicators.volume"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user