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