feat(family-13): add Ichimoku + Heikin-Ashi (#50)

Two new indicators in a brand-new "Ichimoku & alternative charts"
family:

- `Ichimoku` (Ichimoku Kinko Hyo): the full five-line cloud system
  (Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span). Classic
  (9, 26, 52, 26) defaults; configurable. Forward displacement is
  handled in an O(1) ring buffer so the visible Senkou A/B at bar n
  are the values computed at bar n-displacement.
- `HeikinAshi`: recursive candle smoothing transform emitting a
  four-field synthetic candle. Seeds ha_open from (open+close)/2 on
  the first bar.

Touchpoints: core + unit tests, mod.rs/lib.rs re-exports, Python +
Node + WASM bindings (multi-output via PyArray2 / interleaved Vec<f64>
/ Object+Float64Array), Python tests across smoke/new-indicators/
input-validation, Node parity tests, fuzz target (Candle), benches,
README family table + counter (71 -> 73, 8 -> 9 families), CHANGELOG.

Note: Renko, Kagi, and Point & Figure from the family-13 ideas list
are intentionally skipped. They are bar generators (the bar boundary
is defined by price moves, not by a fixed time interval) rather than
indicators that consume a candle stream, and belong in wickra-data
as candle/tick transforms alongside the existing tick-to-candle
aggregator and resampler.
This commit is contained in:
kingchenc
2026-05-25 23:02:29 +02:00
committed by GitHub
parent b971e671b4
commit 5aa0949bce
17 changed files with 1423 additions and 27 deletions
+195
View File
@@ -9924,6 +9924,198 @@ impl PyFama {
}
}
// ============================== Ichimoku ==============================
#[pyclass(name = "Ichimoku", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyIchimoku {
inner: wc::Ichimoku,
}
#[pymethods]
impl PyIchimoku {
#[new]
#[pyo3(signature = (tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26))]
fn new(
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> PyResult<Self> {
Ok(Self {
inner: wc::Ichimoku::new(tenkan_period, kijun_period, senkou_b_period, displacement)
.map_err(map_err)?,
})
}
/// Returns `(tenkan, kijun, senkou_a, senkou_b, chikou)` as a 5-tuple
/// where each element is `float` or `None`.
fn update(
&mut self,
candle: &Bound<'_, PyAny>,
) -> PyResult<
Option<(
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
)>,
> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.tenkan, o.kijun, o.senkou_a, o.senkou_b, o.chikou)))
}
/// Batch over high/low/close numpy columns. Returns shape `(n, 5)` with
/// columns `[tenkan, kijun, senkou_a, senkou_b, chikou]`. Any cell whose
/// underlying line is undefined at that bar is `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<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 n = h.len();
let mut out = vec![f64::NAN; n * 5];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
if let Some(v) = o.tenkan {
out[i * 5] = v;
}
if let Some(v) = o.kijun {
out[i * 5 + 1] = v;
}
if let Some(v) = o.senkou_a {
out[i * 5 + 2] = v;
}
if let Some(v) = o.senkou_b {
out[i * 5 + 3] = v;
}
if let Some(v) = o.chikou {
out[i * 5 + 4] = v;
}
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 5), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn periods(&self) -> (usize, usize, usize, usize) {
self.inner.periods()
}
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 {
let (t, k, sb, d) = self.inner.periods();
format!(
"Ichimoku(tenkan_period={t}, kijun_period={k}, senkou_b_period={sb}, displacement={d})"
)
}
}
// ============================== Heikin-Ashi ==============================
#[pyclass(name = "HeikinAshi", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone, Default)]
struct PyHeikinAshi {
inner: wc::HeikinAshi,
}
#[pymethods]
impl PyHeikinAshi {
#[new]
fn new() -> Self {
Self::default()
}
/// Returns `(ha_open, ha_high, ha_low, ha_close)`.
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.open, o.high, o.low, o.close)))
}
/// Batch over OHLC numpy columns. Returns shape `(n, 4)` with columns
/// `[ha_open, ha_high, ha_low, ha_close]`.
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, PyArray2<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 n = o.len();
let mut out = vec![f64::NAN; n * 4];
for i in 0..n {
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(v) = self.inner.update(candle) {
out[i * 4] = v.open;
out[i * 4 + 1] = v.high;
out[i * 4 + 2] = v.low;
out[i * 4 + 3] = v.close;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out)
.expect("shape consistent")
.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 {
"HeikinAshi()".to_string()
}
}
// ============================== Module ==============================
#[pymodule]
@@ -10096,5 +10288,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySineWave>()?;
m.add_class::<PyMama>()?;
m.add_class::<PyFama>()?;
// Family 13 — Ichimoku & alternative charts
m.add_class::<PyIchimoku>()?;
m.add_class::<PyHeikinAshi>()?;
Ok(())
}