""" Known-value oracle tests: permanent ground truth (Priority 2 - no optional deps). Hand-computable ground truth that never depends on external libraries. These tests encode fundamental mathematical properties and serve as a permanent oracle for correctness. All tests use NO optional dependencies - they run in every CI environment. """ from __future__ import annotations import numpy as np import ferro_ta # --------------------------------------------------------------------------- # SMA Known Values # --------------------------------------------------------------------------- class TestSMAKnownValues: """SMA is the simple average over a window.""" def test_sma_simple_sequence(self): """SMA([1,2,3,4,5], 3) == [nan, nan, 2.0, 3.0, 4.0].""" data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) result = ferro_ta.SMA(data, timeperiod=3) assert np.isnan(result[0]) assert np.isnan(result[1]) assert np.abs(result[2] - 2.0) < 1e-10 # (1+2+3)/3 = 2.0 assert np.abs(result[3] - 3.0) < 1e-10 # (2+3+4)/3 = 3.0 assert np.abs(result[4] - 4.0) < 1e-10 # (3+4+5)/3 = 4.0 def test_sma_period_one_is_identity(self): """SMA with period=1 should be the identity function.""" data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) result = ferro_ta.SMA(data, timeperiod=1) assert np.allclose(result, data, atol=1e-10) def test_sma_constant_series(self): """SMA of constant series should equal that constant.""" data = np.ones(10) * 42.0 result = ferro_ta.SMA(data, timeperiod=5) # After warmup, all values should be 42.0 assert np.allclose(result[4:], 42.0, atol=1e-10) # --------------------------------------------------------------------------- # EMA Known Values # --------------------------------------------------------------------------- class TestEMAKnownValues: """EMA is an exponentially weighted moving average.""" def test_ema_period_one_is_identity(self): """EMA with period=1 should be the identity function (alpha=1).""" data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) result = ferro_ta.EMA(data, timeperiod=1) assert np.allclose(result, data, atol=1e-10) def test_ema_constant_series_converges(self): """EMA of constant series should converge to that constant.""" data = np.ones(100) * 42.0 result = ferro_ta.EMA(data, timeperiod=10) # After sufficient warmup, should converge to 42.0 assert np.allclose(result[-10:], 42.0, atol=1e-6) def test_ema_monotone_rising_is_increasing(self): """EMA of monotone rising series should be strictly increasing after warmup.""" data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50 result = ferro_ta.EMA(data, timeperiod=10) # After warmup, EMA should be strictly increasing for i in range(20, len(result) - 1): assert result[i + 1] > result[i], ( f"EMA not increasing at index {i}: {result[i]} >= {result[i + 1]}" ) # --------------------------------------------------------------------------- # WMA Known Values # --------------------------------------------------------------------------- class TestWMAKnownValues: """WMA is a linearly weighted moving average.""" def test_wma_manual_calculation(self): """WMA([3,5,7], 2) at index 2 = (1*5 + 2*7)/(1+2) = 6.333...""" data = np.array([3.0, 5.0, 7.0]) result = ferro_ta.WMA(data, timeperiod=2) # Index 0: warmup (NaN) assert np.isnan(result[0]) # Index 1: (1*3 + 2*5)/(1+2) = 13/3 = 4.333... expected_1 = (1 * 3.0 + 2 * 5.0) / (1 + 2) assert np.abs(result[1] - expected_1) < 1e-10 # Index 2: (1*5 + 2*7)/(1+2) = 19/3 = 6.333... expected_2 = (1 * 5.0 + 2 * 7.0) / (1 + 2) assert np.abs(result[2] - expected_2) < 1e-10 def test_wma_period_one_is_identity(self): """WMA with period=1 should be the identity function.""" data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) result = ferro_ta.WMA(data, timeperiod=1) assert np.allclose(result, data, atol=1e-10) # --------------------------------------------------------------------------- # BBANDS Known Values # --------------------------------------------------------------------------- class TestBBANDSKnownValues: """Bollinger Bands: middle = SMA, upper/lower = middle ± (nbdevup/nbdevdn * stddev).""" def test_bbands_constant_series(self): """For constant series: upper == middle == lower (stddev=0).""" data = np.ones(20) * 50.0 upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5) # After warmup, all three bands should be 50.0 assert np.allclose(upper[4:], 50.0, atol=1e-10) assert np.allclose(middle[4:], 50.0, atol=1e-10) assert np.allclose(lower[4:], 50.0, atol=1e-10) def test_bbands_middle_is_sma(self): """Middle band should equal SMA.""" data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0]) upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5) sma = ferro_ta.SMA(data, timeperiod=5) assert np.allclose(middle, sma, atol=1e-10, equal_nan=True) def test_bbands_symmetric(self): """Bands should be symmetric: upper-middle == middle-lower (with same nbdev).""" data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0, 18.0, 10.0]) upper, middle, lower = ferro_ta.BBANDS( data, timeperiod=5, nbdevup=2.0, nbdevdn=2.0 ) # After warmup, bands should be symmetric upper_dist = upper[4:] - middle[4:] lower_dist = middle[4:] - lower[4:] assert np.allclose(upper_dist, lower_dist, atol=1e-10) # --------------------------------------------------------------------------- # RSI Known Values # --------------------------------------------------------------------------- class TestRSIKnownValues: """RSI measures momentum: monotone rising → RSI > 50, monotone falling → RSI < 50.""" def test_rsi_monotone_rising(self): """Monotone rising series should produce RSI > 50 after warmup.""" data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50 result = ferro_ta.RSI(data, timeperiod=14) # After warmup, RSI should be > 50 (strong uptrend) assert np.all(result[20:] > 50.0), "RSI of rising series should be > 50" def test_rsi_monotone_falling(self): """Monotone falling series should produce RSI < 50 after warmup.""" data = np.arange(50.0, 0.0, -1.0) # 50, 49, 48, ..., 1 result = ferro_ta.RSI(data, timeperiod=14) # After warmup, RSI should be < 50 (strong downtrend) assert np.all(result[20:] < 50.0), "RSI of falling series should be < 50" def test_rsi_constant_series(self): """Constant series should produce RSI = 100 or NaN (no momentum). Note: For constant series with no change, ferro_ta returns 100 (no downward movement), which is mathematically correct. """ data = np.ones(30) * 42.0 result = ferro_ta.RSI(data, timeperiod=14) # Constant series has no momentum; RSI should be NaN or 100 # ferro_ta returns 100 (no down movement = 100% bullish) valid_values = result[~np.isnan(result)] if len(valid_values) > 0: # Should be either NaN everywhere or 100 everywhere assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all( np.abs(valid_values - 50.0) < 5.0 ), "RSI of constant series should be 100 (no down movement) or close to 50" # --------------------------------------------------------------------------- # ATR Known Values # --------------------------------------------------------------------------- class TestATRKnownValues: """ATR measures volatility: H==L==C → ATR=0.""" def test_atr_zero_range(self): """When H==L==C, ATR should be 0 (no volatility).""" n = 30 high = np.ones(n) * 50.0 low = np.ones(n) * 50.0 close = np.ones(n) * 50.0 result = ferro_ta.ATR(high, low, close, timeperiod=14) # After warmup, ATR should be 0 assert np.allclose(result[14:], 0.0, atol=1e-10) def test_atr_manual_tr_calculation(self): """Manually verify TR formula for 3-bar sequence. Note: ATR requires warmup period. For period=14, first 13 bars are NaN. We test with longer period to see TR values. """ # Bar 0: H=11, L=9, C=10 # Bar 1: H=13, L=10, C=12 → TR = max(13-10, |13-10|, |10-10|) = 3 # Bar 2: H=14, L=11, C=13 → TR = max(14-11, |14-12|, |11-12|) = 3 high = np.array( [ 11.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, ] ) low = np.array( [ 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, ] ) close = np.array( [ 10.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, ] ) # For period=1, ATR still has warmup. Use TRANGE to check TR values directly tr = ferro_ta.TRANGE(high, low, close) # TR[0] = H-L = 11-9 = 2 # TR[1] = max(13-10, |13-10|, |10-10|) = max(3, 3, 0) = 3 # TR[2] = max(14-11, |14-12|, |11-12|) = max(3, 2, 1) = 3 assert np.abs(tr[0] - 2.0) < 1e-10 assert np.abs(tr[1] - 3.0) < 1e-10 assert np.abs(tr[2] - 3.0) < 1e-10 # --------------------------------------------------------------------------- # MOM Known Values # --------------------------------------------------------------------------- class TestMOMKnownValues: """MOM is the difference: close[i] - close[i - period].""" def test_mom_manual_calculation(self): """MOM([10,12,15,11], period=2) == [nan,nan,5,-1].""" data = np.array([10.0, 12.0, 15.0, 11.0]) result = ferro_ta.MOM(data, timeperiod=2) assert np.isnan(result[0]) assert np.isnan(result[1]) assert np.abs(result[2] - 5.0) < 1e-10 # 15 - 10 = 5 assert np.abs(result[3] - (-1.0)) < 1e-10 # 11 - 12 = -1 # --------------------------------------------------------------------------- # ROC Known Values # --------------------------------------------------------------------------- class TestROCKnownValues: """ROC is the percentage change: 100 * (close[i] - close[i-period]) / close[i-period].""" def test_roc_manual_calculation(self): """ROC([10,12], period=1)[1] == 20.0.""" data = np.array([10.0, 12.0]) result = ferro_ta.ROC(data, timeperiod=1) # ROC[1] = 100 * (12 - 10) / 10 = 100 * 0.2 = 20.0 assert np.abs(result[1] - 20.0) < 1e-10 # --------------------------------------------------------------------------- # MACD Known Values # --------------------------------------------------------------------------- class TestMACDKnownValues: """MACD: histogram == macd - signal always.""" def test_macd_histogram_identity(self): """histogram should always equal macd - signal.""" data = np.arange(1.0, 51.0) macd, signal, histogram = ferro_ta.MACD( data, fastperiod=12, slowperiod=26, signalperiod=9 ) # histogram = macd - signal (within floating-point tolerance) expected_histogram = macd - signal assert np.allclose(histogram, expected_histogram, atol=1e-10, equal_nan=True) # --------------------------------------------------------------------------- # VWAP Known Values # --------------------------------------------------------------------------- class TestVWAPKnownValues: """VWAP: period=1 VWAP == TYPPRICE.""" def test_vwap_period_one_equals_typprice(self): """For period=1, VWAP should equal typical price (H+L+C)/3.""" high = np.array([11.0, 13.0, 14.0]) low = np.array([9.0, 10.0, 11.0]) close = np.array([10.0, 12.0, 13.0]) volume = np.array([1000.0, 1000.0, 1000.0]) result = ferro_ta.VWAP(high, low, close, volume, timeperiod=1) expected = ferro_ta.TYPPRICE(high, low, close) assert np.allclose(result, expected, atol=1e-10) def test_vwap_cumulative_manual(self): """Manually verify cumulative VWAP for simple 3-bar sequence.""" # Bar 0: TP=10, Vol=100 → VWAP = (10*100)/(100) = 10.0 # Bar 1: TP=12, Vol=200 → VWAP = (10*100 + 12*200)/(100+200) = 3400/300 = 11.333... # Bar 2: TP=11, Vol=150 → VWAP = (10*100 + 12*200 + 11*150)/(100+200+150) = 5050/450 = 11.222... high = np.array([11.0, 13.0, 12.0]) low = np.array([9.0, 11.0, 10.0]) close = np.array([10.0, 12.0, 11.0]) volume = np.array([100.0, 200.0, 150.0]) result = ferro_ta.VWAP(high, low, close, volume, timeperiod=0) # cumulative typ = (high + low + close) / 3.0 expected_0 = typ[0] expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1]) expected_2 = (typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]) / ( volume[0] + volume[1] + volume[2] ) assert np.abs(result[0] - expected_0) < 1e-10 assert np.abs(result[1] - expected_1) < 1e-10 assert np.abs(result[2] - expected_2) < 1e-10 # --------------------------------------------------------------------------- # DONCHIAN Known Values # --------------------------------------------------------------------------- class TestDONCHIANKnownValues: """DONCHIAN: upper = MAX(high), lower = MIN(low), middle = (upper+lower)/2.""" def test_donchian_structure(self): """upper == MAX(high), lower == MIN(low), middle == (upper+lower)/2.""" high = np.array([11.0, 13.0, 14.0, 12.0, 15.0]) low = np.array([9.0, 10.0, 11.0, 10.0, 12.0]) period = 3 upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period) # upper should match rolling max of high max_high = ferro_ta.MAX(high, timeperiod=period) assert np.allclose(upper, max_high, atol=1e-10, equal_nan=True) # lower should match rolling min of low min_low = ferro_ta.MIN(low, timeperiod=period) assert np.allclose(lower, min_low, atol=1e-10, equal_nan=True) # middle should be (upper + lower) / 2 expected_middle = (upper + lower) / 2.0 assert np.allclose(middle, expected_middle, atol=1e-10, equal_nan=True) # --------------------------------------------------------------------------- # PIVOT_POINTS Known Values # --------------------------------------------------------------------------- class TestPIVOT_POINTSKnownValues: """PIVOT_POINTS classic formula: P=(H+L+C)/3, R1=2P-L, S1=2P-H, R2=P+(H-L), S2=P-(H-L).""" def test_pivot_points_classic_formula(self): """Given H=110, L=90, C=100: P=100, R1=110, S1=90, R2=120, S2=80. Note: PIVOT_POINTS operates on OHLC bars. Single bar produces valid pivots. """ high = np.array([110.0, 110.0]) # Need at least 2 bars low = np.array([90.0, 90.0]) close = np.array([100.0, 100.0]) pivot, r1, s1, r2, s2 = ferro_ta.PIVOT_POINTS( high, low, close, method="classic" ) # Check last bar (index 1) which has full history # P = (110 + 90 + 100) / 3 = 100 assert np.abs(pivot[1] - 100.0) < 1e-10 # R1 = 2*P - L = 2*100 - 90 = 110 assert np.abs(r1[1] - 110.0) < 1e-10 # S1 = 2*P - H = 2*100 - 110 = 90 assert np.abs(s1[1] - 90.0) < 1e-10 # R2 = P + (H - L) = 100 + 20 = 120 assert np.abs(r2[1] - 120.0) < 1e-10 # S2 = P - (H - L) = 100 - 20 = 80 assert np.abs(s2[1] - 80.0) < 1e-10 # --------------------------------------------------------------------------- # Statistic Known Values # --------------------------------------------------------------------------- class TestStatisticKnownValues: """Statistical functions: correlation, linear regression.""" def test_linearreg_slope_of_linear_sequence(self): """LINEARREG_SLOPE([0,1,2,3,4], 5) == 1.0.""" data = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) result = ferro_ta.LINEARREG_SLOPE(data, timeperiod=5) # Last value should be slope = 1.0 assert np.abs(result[-1] - 1.0) < 1e-10 def test_correl_x_with_x_is_one(self): """CORREL(x, x) should be 1.0.""" x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) result = ferro_ta.CORREL(x, x, timeperiod=5) # After warmup, correlation should be 1.0 assert np.allclose(result[4:], 1.0, atol=1e-10) def test_correl_x_with_negative_x_is_minus_one(self): """CORREL(x, -x) should be -1.0.""" x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) neg_x = -x result = ferro_ta.CORREL(x, neg_x, timeperiod=5) # After warmup, correlation should be -1.0 assert np.allclose(result[4:], -1.0, atol=1e-10) # --------------------------------------------------------------------------- # Pattern Known Values # --------------------------------------------------------------------------- class TestPatternKnownValues: """Candlestick patterns: construct known-good OHLC sequences.""" def test_doji_known_sequence(self): """Construct a perfect doji: open == close, small body.""" # Doji: open == close (or very close), H and L have range high = np.array([11.0, 11.0, 11.0, 11.0, 11.0]) low = np.array([9.0, 9.0, 9.0, 9.0, 9.0]) close = np.array([10.0, 10.0, 10.0, 10.0, 10.0]) open_ = np.array([10.0, 10.0, 10.0, 10.0, 10.0]) result = ferro_ta.CDLDOJI(open_, high, low, close) # Should detect doji (non-zero pattern) # At least some values should be non-zero assert np.any(result != 0), "CDLDOJI should detect perfect doji pattern" def test_engulfing_known_sequence(self): """Construct a bullish engulfing pattern.""" # Bullish engulfing: bar[i-1] is bearish (O > C), bar[i] is bullish (C > O) and engulfs bar[i-1] # Bar 0: O=12, H=12, L=10, C=10 (bearish) # Bar 1: O=9, H=13, L=9, C=13 (bullish, engulfs bar 0) open_ = np.array([12.0, 9.0]) high = np.array([12.0, 13.0]) low = np.array([10.0, 9.0]) close = np.array([10.0, 13.0]) result = ferro_ta.CDLENGULFING(open_, high, low, close) # Should detect engulfing at index 1 assert result[1] != 0, "CDLENGULFING should detect bullish engulfing pattern" def test_hammer_known_sequence(self): """Construct a hammer pattern: small body at top, long lower shadow.""" # Hammer: small body, long lower shadow (>= 2x body), little/no upper shadow # O=11, H=11.5, L=9, C=11 → body=0, lower_shadow=2, upper_shadow=0.5 open_ = np.array([11.0]) high = np.array([11.5]) low = np.array([9.0]) close = np.array([11.0]) result = ferro_ta.CDLHAMMER(open_, high, low, close) # Should detect hammer (non-zero) # Note: hammer detection depends on lookback, so we test multiple bars open_ = np.array([10.0, 10.5, 11.0]) high = np.array([10.5, 11.0, 11.5]) low = np.array([9.5, 10.0, 9.0]) close = np.array([10.0, 10.5, 11.0]) result = ferro_ta.CDLHAMMER(open_, high, low, close) # Last bar has hammer characteristics # (actual detection may vary based on implementation) assert result.shape == close.shape