Files
2026-07-09 05:08:16 +08:00

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