feat: add Candlestick Patterns deepening (B14, 6 indicators) (#209)
B14 of the family-deepening roadmap — six candlestick patterns (479 -> 485), all in the **Candlestick Patterns** family. **Fixed-lookback (candle-pattern macro bindings, neutral 0.0 during warmup):** - **Tristar** — three-doji star reversal. - **Harami Cross** — Harami whose second candle is a contained doji. - **Tower Top/Bottom** — tall bar, small pause, tall opposite bar. **Windowed / parameterized (hand-bound, `candle -> f64`):** - **Frying Pan Bottom** — rounded U-shaped accumulation base, recovery-confirmed. - **Dumpling Top** — rounded dome-shaped distribution top, breakdown-confirmed. - **New Price Lines** — run of N consecutive new closing highs (+1) / lows (-1). Window/Gap (Rising-Falling) dropped (SKIP — existing gap coverage). Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs counter (485) and CHANGELOG. Verified: core 3966 + doc 435, clippy clean, node 560, python 922.
This commit is contained in:
@@ -360,6 +360,12 @@ from ._wickra import (
|
||||
KagiBars,
|
||||
PointAndFigureBars,
|
||||
# Candlestick patterns
|
||||
TowerTopBottom,
|
||||
HaramiCross,
|
||||
Tristar,
|
||||
FryPanBottom,
|
||||
DumplingTop,
|
||||
NewPriceLines,
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
@@ -869,6 +875,12 @@ __all__ = [
|
||||
"KagiBars",
|
||||
"PointAndFigureBars",
|
||||
# Candlestick patterns
|
||||
"TowerTopBottom",
|
||||
"HaramiCross",
|
||||
"Tristar",
|
||||
"FryPanBottom",
|
||||
"DumplingTop",
|
||||
"NewPriceLines",
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
|
||||
@@ -15705,6 +15705,186 @@ impl PyCandleVolume {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Frying Pan Bottom ==============================
|
||||
|
||||
#[pyclass(name = "FryPanBottom", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyFryPanBottom {
|
||||
inner: wc::FryPanBottom,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFryPanBottom {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=9))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::FryPanBottom::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
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>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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 h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Dumpling Top ==============================
|
||||
|
||||
#[pyclass(name = "DumplingTop", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyDumplingTop {
|
||||
inner: wc::DumplingTop,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDumplingTop {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=9))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DumplingTop::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
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>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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 h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== New Price Lines ==============================
|
||||
|
||||
#[pyclass(name = "NewPriceLines", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyNewPriceLines {
|
||||
inner: wc::NewPriceLines,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyNewPriceLines {
|
||||
#[new]
|
||||
#[pyo3(signature = (count=5))]
|
||||
fn new(count: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::NewPriceLines::new(count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
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>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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 h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== CoefficientOfVariation ==============================
|
||||
|
||||
#[pyclass(
|
||||
@@ -18181,6 +18361,9 @@ candle_pattern_no_param!(PyTdClop, wc::TdClop, "TDClop");
|
||||
candle_pattern_no_param!(PyTdClopwin, wc::TdClopwin, "TDClopwin");
|
||||
candle_pattern_no_param!(PyTdPropulsion, wc::TdPropulsion, "TDPropulsion");
|
||||
candle_pattern_no_param!(PyTdTrap, wc::TdTrap, "TDTrap");
|
||||
candle_pattern_no_param!(PyTristar, wc::Tristar, "Tristar");
|
||||
candle_pattern_no_param!(PyHaramiCross, wc::HaramiCross, "HaramiCross");
|
||||
candle_pattern_no_param!(PyTowerTopBottom, wc::TowerTopBottom, "TowerTopBottom");
|
||||
// ============================== Microstructure: Order Book ==============================
|
||||
//
|
||||
// Order-book indicators consume a depth snapshot rather than OHLCV. Streaming
|
||||
@@ -24826,5 +25009,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyTdClopwin>()?;
|
||||
m.add_class::<PyTdPropulsion>()?;
|
||||
m.add_class::<PyTdTrap>()?;
|
||||
m.add_class::<PyTristar>()?;
|
||||
m.add_class::<PyHaramiCross>()?;
|
||||
m.add_class::<PyTowerTopBottom>()?;
|
||||
m.add_class::<PyFryPanBottom>()?;
|
||||
m.add_class::<PyDumplingTop>()?;
|
||||
m.add_class::<PyNewPriceLines>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -382,6 +382,30 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"FryPanBottom": (
|
||||
lambda: ta.FryPanBottom(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"NewPriceLines": (
|
||||
lambda: ta.NewPriceLines(5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"DumplingTop": (
|
||||
lambda: ta.DumplingTop(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TowerTopBottom": (
|
||||
lambda: ta.TowerTopBottom(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"HaramiCross": (
|
||||
lambda: ta.HaramiCross(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Tristar": (
|
||||
lambda: ta.Tristar(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ThreeLineBreak": (
|
||||
lambda: ta.ThreeLineBreak(3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
@@ -3278,6 +3302,27 @@ def test_equivolume_reference():
|
||||
def test_candle_volume_reference():
|
||||
t = ta.CandleVolume(20)
|
||||
|
||||
|
||||
def test_tristar_reference():
|
||||
t = ta.Tristar()
|
||||
assert t.update((100.0, 101.0, 99.0, 100.02, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 106.0, 104.0, 105.02, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 101.0, 99.0, 100.02, 1.0, 2)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_harami_cross_reference():
|
||||
t = ta.HaramiCross()
|
||||
assert t.update((110.0, 110.2, 99.8, 100.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 106.0, 104.0, 105.02, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_tower_top_bottom_reference():
|
||||
t = ta.TowerTopBottom()
|
||||
assert t.update((100.0, 110.1, 99.9, 110.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 107.0, 103.0, 105.1, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((110.0, 110.1, 99.9, 100.0, 1.0, 2)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user