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

261 lines
8.1 KiB
Python

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