485 lines
15 KiB
Python
485 lines
15 KiB
Python
"""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
|