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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user