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:
@@ -219,6 +219,22 @@ from ._wickra import (
|
||||
ValueArea,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Candlestick patterns
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
HangingMan,
|
||||
ShootingStar,
|
||||
Engulfing,
|
||||
Harami,
|
||||
MorningEveningStar,
|
||||
ThreeSoldiersOrCrows,
|
||||
PiercingDarkCloud,
|
||||
Marubozu,
|
||||
Tweezer,
|
||||
SpinningTop,
|
||||
ThreeInside,
|
||||
ThreeOutside,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -417,4 +433,20 @@ __all__ = [
|
||||
"ValueArea",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Candlestick patterns
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
"HangingMan",
|
||||
"ShootingStar",
|
||||
"Engulfing",
|
||||
"Harami",
|
||||
"MorningEveningStar",
|
||||
"ThreeSoldiersOrCrows",
|
||||
"PiercingDarkCloud",
|
||||
"Marubozu",
|
||||
"Tweezer",
|
||||
"SpinningTop",
|
||||
"ThreeInside",
|
||||
"ThreeOutside",
|
||||
]
|
||||
|
||||
@@ -11054,6 +11054,109 @@ impl PyOpeningRange {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Candlestick Patterns ==============================
|
||||
//
|
||||
// All 15 patterns take Candles and emit a signed f64 signal per bar:
|
||||
// +1.0 bullish, -1.0 bearish, 0.0 no pattern. Doji is direction-less, so it
|
||||
// uses +1.0 / 0.0 only.
|
||||
|
||||
macro_rules! candle_pattern_no_param {
|
||||
($name:ident, $inner:ty, $repr:expr) => {
|
||||
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct $name {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl $name {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let o = open
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"open, high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(o.len());
|
||||
for i in 0..o.len() {
|
||||
let candle =
|
||||
wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{}()", $repr)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
candle_pattern_no_param!(PyDoji, wc::Doji, "Doji");
|
||||
candle_pattern_no_param!(PyHammer, wc::Hammer, "Hammer");
|
||||
candle_pattern_no_param!(PyInvertedHammer, wc::InvertedHammer, "InvertedHammer");
|
||||
candle_pattern_no_param!(PyHangingMan, wc::HangingMan, "HangingMan");
|
||||
candle_pattern_no_param!(PyShootingStar, wc::ShootingStar, "ShootingStar");
|
||||
candle_pattern_no_param!(PyEngulfing, wc::Engulfing, "Engulfing");
|
||||
candle_pattern_no_param!(PyHarami, wc::Harami, "Harami");
|
||||
candle_pattern_no_param!(
|
||||
PyMorningEveningStar,
|
||||
wc::MorningEveningStar,
|
||||
"MorningEveningStar"
|
||||
);
|
||||
candle_pattern_no_param!(
|
||||
PyThreeSoldiersOrCrows,
|
||||
wc::ThreeSoldiersOrCrows,
|
||||
"ThreeSoldiersOrCrows"
|
||||
);
|
||||
candle_pattern_no_param!(
|
||||
PyPiercingDarkCloud,
|
||||
wc::PiercingDarkCloud,
|
||||
"PiercingDarkCloud"
|
||||
);
|
||||
candle_pattern_no_param!(PyMarubozu, wc::Marubozu, "Marubozu");
|
||||
candle_pattern_no_param!(PyTweezer, wc::Tweezer, "Tweezer");
|
||||
candle_pattern_no_param!(PySpinningTop, wc::SpinningTop, "SpinningTop");
|
||||
candle_pattern_no_param!(PyThreeInside, wc::ThreeInside, "ThreeInside");
|
||||
candle_pattern_no_param!(PyThreeOutside, wc::ThreeOutside, "ThreeOutside");
|
||||
|
||||
// ============================== Module ==============================
|
||||
|
||||
#[pymodule]
|
||||
@@ -11245,5 +11348,21 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyValueArea>()?;
|
||||
m.add_class::<PyInitialBalance>()?;
|
||||
m.add_class::<PyOpeningRange>()?;
|
||||
// Candlestick patterns.
|
||||
m.add_class::<PyDoji>()?;
|
||||
m.add_class::<PyHammer>()?;
|
||||
m.add_class::<PyInvertedHammer>()?;
|
||||
m.add_class::<PyHangingMan>()?;
|
||||
m.add_class::<PyShootingStar>()?;
|
||||
m.add_class::<PyEngulfing>()?;
|
||||
m.add_class::<PyHarami>()?;
|
||||
m.add_class::<PyMorningEveningStar>()?;
|
||||
m.add_class::<PyThreeSoldiersOrCrows>()?;
|
||||
m.add_class::<PyPiercingDarkCloud>()?;
|
||||
m.add_class::<PyMarubozu>()?;
|
||||
m.add_class::<PyTweezer>()?;
|
||||
m.add_class::<PySpinningTop>()?;
|
||||
m.add_class::<PyThreeInside>()?;
|
||||
m.add_class::<PyThreeOutside>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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