feat(family-14): add 15 candlestick patterns (#53)
* feat(family-14): add 15 candlestick patterns Introduces the Candlestick Patterns family (block A of the family-14 spec) as scalar f64 indicators on Candle inputs. Each detector emits +1.0 for a bullish reading, -1.0 for a bearish reading, and 0.0 when no pattern is present. Doji is direction-less and emits +1.0 / 0.0 only. New indicators (15): - Doji - Hammer - InvertedHammer - HangingMan - ShootingStar - Engulfing - Harami - MorningEveningStar (signed: +1.0 morning star, -1.0 evening star) - ThreeSoldiersOrCrows (signed: +1.0 soldiers, -1.0 crows) - PiercingDarkCloud (signed: +1.0 piercing, -1.0 dark cloud) - Marubozu (signed: +1.0 bullish, -1.0 bearish, 5 percent shadow tolerance default) - Tweezer (signed: +1.0 bottom, -1.0 top, 10 bps relative tolerance default) - SpinningTop (direction-signed indecision) - ThreeInside (confirmed Harami) - ThreeOutside (confirmed Engulfing) MVP scope notes: - Pattern-shape check only, no trend filter applied. Caller combines with a trend indicator for actionable signals. Documented in every doc comment. - Block B (Harmonic patterns) and block C (Chart patterns) remain out-of-scope and will follow when the pattern-detection framework (pivot detector, multi-bar state machines) lands. Touched across all bindings: Python, Node, WASM. Fuzz target, Python tests (streaming-vs-batch + reference values), Node tests (streaming-vs-batch + reference values), and a representative bench subset (1-, 2- and 3-bar patterns) added. README family table + indicator counter (71 -> 86, eight -> nine families) and CHANGELOG [Unreleased] updated. * fix(family-14): unpack MULTI values with *_ to handle 3-element tuples * cov(family-14): cover Default impl cold paths and MorningEveningStar guard branches
This commit is contained in:
@@ -325,6 +325,68 @@ CANDLE_SCALAR = {
|
||||
lambda: ta.TDDifferential(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
# Candlestick patterns -- batch takes (open, high, low, close) and the
|
||||
# streaming side feeds close as open (same convention as BalanceOfPower).
|
||||
"Doji": (
|
||||
lambda: ta.Doji(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Hammer": (
|
||||
lambda: ta.Hammer(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"InvertedHammer": (
|
||||
lambda: ta.InvertedHammer(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"HangingMan": (
|
||||
lambda: ta.HangingMan(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ShootingStar": (
|
||||
lambda: ta.ShootingStar(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Engulfing": (
|
||||
lambda: ta.Engulfing(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Harami": (
|
||||
lambda: ta.Harami(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"MorningEveningStar": (
|
||||
lambda: ta.MorningEveningStar(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ThreeSoldiersOrCrows": (
|
||||
lambda: ta.ThreeSoldiersOrCrows(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"PiercingDarkCloud": (
|
||||
lambda: ta.PiercingDarkCloud(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Marubozu": (
|
||||
lambda: ta.Marubozu(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Tweezer": (
|
||||
lambda: ta.Tweezer(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"SpinningTop": (
|
||||
lambda: ta.SpinningTop(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ThreeInside": (
|
||||
lambda: ta.ThreeInside(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ThreeOutside": (
|
||||
lambda: ta.ThreeOutside(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1551,3 +1613,117 @@ def test_heikin_ashi_lifecycle_and_reset():
|
||||
assert ha.is_ready() is True
|
||||
ha.reset()
|
||||
assert ha.is_ready() is False
|
||||
# --- Candlestick pattern reference values --------------------------------
|
||||
|
||||
|
||||
def test_doji_reference():
|
||||
# body 0, range 2 -> doji.
|
||||
assert ta.Doji().update((10.0, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(1.0)
|
||||
# Marubozu shape -> not a doji.
|
||||
assert ta.Doji().update((10.0, 12.0, 10.0, 12.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_hammer_reference():
|
||||
# body 0.5, lower shadow 5.0, upper 0.1.
|
||||
assert ta.Hammer().update((10.0, 10.6, 5.0, 10.5, 1.0, 0)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_inverted_hammer_reference():
|
||||
assert ta.InvertedHammer().update(
|
||||
(10.0, 15.0, 9.9, 10.5, 1.0, 0)
|
||||
) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_hanging_man_reference():
|
||||
assert ta.HangingMan().update((10.0, 10.6, 5.0, 10.5, 1.0, 0)) == pytest.approx(
|
||||
-1.0
|
||||
)
|
||||
|
||||
|
||||
def test_shooting_star_reference():
|
||||
assert ta.ShootingStar().update(
|
||||
(10.0, 15.0, 9.9, 10.5, 1.0, 0)
|
||||
) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_engulfing_reference():
|
||||
e = ta.Engulfing()
|
||||
assert e.update((11.0, 11.2, 9.8, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert e.update((9.5, 12.0, 9.5, 11.5, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_harami_reference():
|
||||
h = ta.Harami()
|
||||
assert h.update((12.0, 12.5, 9.5, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert h.update((10.5, 11.5, 10.4, 11.0, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_morning_evening_star_reference():
|
||||
m = ta.MorningEveningStar()
|
||||
assert m.update((12.0, 12.2, 9.5, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert m.update((9.9, 10.1, 9.7, 9.95, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert m.update((10.1, 12.0, 10.0, 11.8, 1.0, 2)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_three_soldiers_reference():
|
||||
t = ta.ThreeSoldiersOrCrows()
|
||||
assert t.update((10.0, 11.5, 9.9, 11.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((10.5, 12.5, 10.4, 12.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((11.5, 13.5, 11.4, 13.0, 1.0, 2)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_piercing_dark_cloud_reference():
|
||||
p = ta.PiercingDarkCloud()
|
||||
assert p.update((12.0, 12.5, 10.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert p.update((9.8, 11.8, 9.5, 11.5, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_marubozu_reference():
|
||||
# Bullish marubozu: open == low, close == high.
|
||||
assert ta.Marubozu().update(
|
||||
(10.0, 12.0, 10.0, 12.0, 1.0, 0)
|
||||
) == pytest.approx(1.0)
|
||||
assert ta.Marubozu().update(
|
||||
(12.0, 12.0, 10.0, 10.0, 1.0, 0)
|
||||
) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_tweezer_reference():
|
||||
t = ta.Tweezer()
|
||||
assert t.update((11.0, 12.0, 9.5, 9.6, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((9.7, 10.5, 9.5, 10.2, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_spinning_top_reference():
|
||||
# body 0.5, both shadows 3.0, range 6.5 -> body/range ~= 0.077.
|
||||
assert ta.SpinningTop().update(
|
||||
(10.0, 13.5, 7.0, 10.5, 1.0, 0)
|
||||
) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_three_inside_reference():
|
||||
t = ta.ThreeInside()
|
||||
assert t.update((12.0, 12.5, 9.5, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((10.5, 11.5, 10.4, 11.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((11.0, 13.0, 10.9, 12.5, 1.0, 2)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_three_outside_reference():
|
||||
t = ta.ThreeOutside()
|
||||
assert t.update((11.0, 11.2, 9.8, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((9.5, 12.0, 9.5, 11.5, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((11.5, 13.0, 11.4, 12.5, 1.0, 2)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_new_indicators_expose_lifecycle():
|
||||
instances = [make() for make, _ in CANDLE_SCALAR.values()]
|
||||
instances += [make() for make, *_ in MULTI.values()]
|
||||
instances += [cls(*args) for cls, args in SCALAR]
|
||||
for ind in instances:
|
||||
assert ind.is_ready() is False
|
||||
assert ind.warmup_period() >= 1
|
||||
ind.reset()
|
||||
assert ind.is_ready() is False
|
||||
|
||||
Reference in New Issue
Block a user