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
@@ -199,6 +199,9 @@ from ._wickra import (
TDDifferential,
TDOpen,
TDRiskLevel,
# Ichimoku & alternative charts
Ichimoku,
HeikinAshi,
)
__all__ = [
@@ -377,4 +380,7 @@ __all__ = [
"TDDifferential",
"TDOpen",
"TDRiskLevel",
# Ichimoku & alternative charts
"Ichimoku",
"HeikinAshi",
]
+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(())
}
@@ -41,6 +41,18 @@ def test_roc_and_trix_have_default_periods():
assert ta.TRIX() is not None
def test_ichimoku_rejects_zero_and_non_increasing_periods():
with pytest.raises(ValueError):
ta.Ichimoku(0, 26, 52, 26)
with pytest.raises(ValueError):
ta.Ichimoku(9, 26, 52, 0)
# Periods must satisfy tenkan < kijun < senkou_b.
with pytest.raises(ValueError):
ta.Ichimoku(26, 9, 52, 26)
with pytest.raises(ValueError):
ta.Ichimoku(9, 52, 52, 26)
def test_family_10_ehlers_rejects_invalid_parameters():
with pytest.raises(ValueError):
ta.SuperSmoother(0)
@@ -1285,3 +1285,102 @@ def test_new_indicators_expose_lifecycle():
assert ind.warmup_period() >= 1
ind.reset()
assert ind.is_ready() is False
# --- Ichimoku & Heikin-Ashi (Family 13) -----------------------------------
def test_ichimoku_batch_shape_and_warmup(ohlcv):
high, low, close, _ = ohlcv
ichi = ta.Ichimoku()
out = ichi.batch(high, low, close)
assert out.shape == (close.size, 5)
# Warmup is 77 for the classic (9, 26, 52, 26) configuration.
assert ichi.warmup_period() == 77
# Tenkan emits from bar 9; before that, the entire column is NaN.
assert np.all(np.isnan(out[:8, 0]))
assert not math.isnan(out[8, 0])
def test_ichimoku_streaming_matches_batch(ohlcv):
high, low, close, _ = ohlcv
batch = ta.Ichimoku().batch(high, low, close)
streamer = ta.Ichimoku()
rows = []
for i in range(close.size):
candle = (
float(close[i]),
float(high[i]),
float(low[i]),
float(close[i]),
0.0,
i,
)
v = streamer.update(candle)
if v is None:
rows.append([math.nan] * 5)
else:
rows.append([math.nan if x is None else float(x) for x in v])
assert _eq_nan(batch, np.array(rows, dtype=np.float64))
def test_ichimoku_chikou_is_close_displacement_back():
# A constant series makes Chikou trivially equal to the close.
n = 60
close = np.full(n, 100.0)
high = close + 1.0
low = close - 1.0
out = ta.Ichimoku().batch(high, low, close)
# Displacement = 26, so chikou is defined from bar 25 onwards.
for i in range(25, n):
assert out[i, 4] == pytest.approx(100.0)
def test_heikin_ashi_seed_and_recursion():
ha = ta.HeikinAshi()
# Bar 1: ha_open = (open + close) / 2, ha_close = (O+H+L+C)/4.
first = ha.update((10.0, 12.0, 9.0, 11.0, 0.0, 0))
assert first == pytest.approx(
(
(10.0 + 11.0) / 2.0,
max(12.0, (10.0 + 11.0) / 2.0, (10.0 + 12.0 + 9.0 + 11.0) / 4.0),
min(9.0, (10.0 + 11.0) / 2.0, (10.0 + 12.0 + 9.0 + 11.0) / 4.0),
(10.0 + 12.0 + 9.0 + 11.0) / 4.0,
)
)
# Bar 2: ha_open = (prev_ha_open + prev_ha_close) / 2.
second = ha.update((11.5, 13.0, 10.5, 12.0, 0.0, 1))
assert second[0] == pytest.approx((first[0] + first[3]) / 2.0)
assert second[3] == pytest.approx((11.5 + 13.0 + 10.5 + 12.0) / 4.0)
def test_heikin_ashi_streaming_matches_batch(ohlcv):
high, low, close, _ = ohlcv
open_ = (high + low) / 2.0
batch = ta.HeikinAshi().batch(open_, high, low, close)
streamer = ta.HeikinAshi()
rows = []
for i in range(close.size):
candle = (
float(open_[i]),
float(high[i]),
float(low[i]),
float(close[i]),
0.0,
i,
)
v = streamer.update(candle)
rows.append([math.nan] * 4 if v is None else list(v))
assert _eq_nan(batch, np.array(rows, dtype=np.float64))
def test_heikin_ashi_lifecycle_and_reset():
ha = ta.HeikinAshi()
assert ha.is_ready() is False
assert ha.warmup_period() == 1
ha.update((10.0, 11.0, 9.0, 10.5, 0.0, 0))
assert ha.is_ready() is True
ha.reset()
assert ha.is_ready() is False
+13
View File
@@ -57,6 +57,19 @@ def test_obv_batch_shape(ohlc_series):
assert out.shape == close.shape
def test_ichimoku_batch_returns_n_by_5(ohlc_series):
high, low, close = ohlc_series
out = ta.Ichimoku().batch(high, low, close)
assert out.shape == (close.size, 5)
def test_heikin_ashi_batch_returns_n_by_4(ohlc_series):
high, low, close = ohlc_series
open_ = (high + low) / 2.0
out = ta.HeikinAshi().batch(open_, high, low, close)
assert out.shape == (close.size, 4)
def test_ehlers_super_smoother_batch_shape(sine_prices):
out = ta.SuperSmoother(10).batch(sine_prices)
assert out.shape == sine_prices.shape