"""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