From 5aa0949bcec7e931d83fd51d140cc205420cdeac Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 25 May 2026 23:02:29 +0200 Subject: [PATCH] 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 / 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. --- CHANGELOG.md | 16 + README.md | 5 +- bindings/node/__tests__/indicators.test.js | 24 + bindings/node/index.js | 4 +- bindings/node/src/lib.rs | 194 ++++++++ bindings/python/python/wickra/__init__.py | 6 + bindings/python/src/lib.rs | 195 ++++++++ .../python/tests/test_input_validation.py | 12 + bindings/python/tests/test_new_indicators.py | 99 ++++ bindings/python/tests/test_smoke.py | 13 + bindings/wasm/src/lib.rs | 176 +++++++ .../wickra-core/src/indicators/heikin_ashi.rs | 198 ++++++++ crates/wickra-core/src/indicators/ichimoku.rs | 434 ++++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 4 + crates/wickra-core/src/lib.rs | 7 +- crates/wickra/benches/indicators.rs | 22 +- fuzz/fuzz_targets/indicator_update_candle.rs | 41 +- 17 files changed, 1423 insertions(+), 27 deletions(-) create mode 100644 crates/wickra-core/src/indicators/heikin_ashi.rs create mode 100644 crates/wickra-core/src/indicators/ichimoku.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4cf565..d26cc63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 13 — Ichimoku & alternative charts.** Two new indicators: + - `Ichimoku` (Ichimoku Kinko Hyo) — the full five-line cloud system + (Tenkan-sen, Kijun-sen, Senkou Span A/B, Chikou Span) with the + classic `(9, 26, 52, 26)` defaults and configurable periods. Forward + displacement is handled in a streaming ring buffer so the + currently-visible Senkou A/B at bar *n* are the values computed + from bar *n − displacement*. + - `HeikinAshi` — the candle smoothing transform that recursively + averages OHLC into a four-component output (`ha_open`, `ha_high`, + `ha_low`, `ha_close`). Seeds `ha_open` from the first bar's + `(open + close) / 2`. + + Exposed in all four bindings (Rust, Python, Node, WASM). Renko, + Kagi, and Point & Figure from the family ideas list are deferred: + they are custom bar generators rather than indicators and belong in + `wickra-data`. - **Family 10 — Ehlers / Cycle (DSP) indicators.** 16 new streaming-first indicators implementing John Ehlers' digital-signal-processing school of cycle analytics — a strong diff --git a/README.md b/README.md index 93f6797a..6a968c22 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -163 streaming-first indicators across twelve families. Every one passes the +165 streaming-first indicators across thirteen families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -127,6 +127,7 @@ semantics tests. | Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline | | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | | DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level | +| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi | Adding a new indicator means implementing one trait in Rust; all four bindings inherit it automatically. @@ -199,7 +200,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 147 indicators +│ ├── wickra-core/ core engine + all 165 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index c6deb1b1..a6129870 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -231,6 +231,9 @@ const multi = { TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, // Family 10: Ehlers / Cycle (multi-output) MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) }, + // Family 13: Ichimoku & alternative charts + Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, }; for (const [name, d] of Object.entries(multi)) { @@ -357,6 +360,27 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { assert.ok(Math.abs(out[4] - 45) < 1e-9); }); +test('Ichimoku classic warmup is 77 and tenkan emits at bar 9', () => { + const ichi = new wickra.Ichimoku(9, 26, 52, 26); + assert.equal(ichi.warmupPeriod(), 77); + const n = 30; + const h = Array.from({ length: n }, (_, i) => 100 + i + 2); + const l = Array.from({ length: n }, (_, i) => 100 + i - 2); + const c = Array.from({ length: n }, (_, i) => 100 + i + 1); + const out = ichi.batch(h, l, c); + for (let i = 0; i < 8; i++) { + assert.ok(Number.isNaN(out[i * 5]), `tenkan should be NaN at bar ${i}`); + } + assert.ok(!Number.isNaN(out[8 * 5]), 'tenkan should be defined at bar 9'); +}); + +test('HeikinAshi first bar seeds from real open and close', () => { + const ha = new wickra.HeikinAshi(); + const out = ha.update(10, 12, 9, 11); + assert.ok(Math.abs(out.open - (10 + 11) / 2) < 1e-12); + assert.ok(Math.abs(out.close - (10 + 12 + 9 + 11) / 4) < 1e-12); +}); + test('PercentageTrailingStop seeds and ratchets', () => { const s = new wickra.PercentageTrailingStop(10); assert.ok(Math.abs(s.update(100) - 90) < 1e-9); diff --git a/bindings/node/index.js b/bindings/node/index.js index 89296238..1d1d8840 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, RVI, PGO, KST, SMI, LaguerreRSI, ConnorsRSI, Inertia, ALMA, McGinleyDynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, APO, AwesomeOscillatorHistogram, CFO, ZeroLagMACD, ElderImpulse, STC, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, KVO, VolumeOscillator, NVI, PVI, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, RWI, WaveTrend, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, RVIVolatility, ParkinsonVolatility, GarmanKlassVolatility, RogersSatchellVolatility, YangZhangVolatility, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, SuperSmoother, FisherTransform, InverseFisherTransform, Decycler, DecyclerOscillator, RoofingFilter, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -477,3 +477,5 @@ module.exports.AdaptiveCycle = AdaptiveCycle module.exports.SineWave = SineWave module.exports.MAMA = MAMA module.exports.FAMA = FAMA +module.exports.Ichimoku = Ichimoku +module.exports.HeikinAshi = HeikinAshi diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index faba107a..dfb9585b 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -7746,3 +7746,197 @@ impl FamaNode { self.inner.warmup_period() as u32 } } + +// ============================== Ichimoku ============================== + +/// Ichimoku output: five lines, any of which may be `NaN` while the indicator +/// is still warming up. +#[napi(object)] +pub struct IchimokuValue { + pub tenkan: f64, + pub kijun: f64, + #[napi(js_name = "senkouA")] + pub senkou_a: f64, + #[napi(js_name = "senkouB")] + pub senkou_b: f64, + pub chikou: f64, +} + +#[napi(js_name = "Ichimoku")] +pub struct IchimokuNode { + inner: wc::Ichimoku, +} + +#[napi] +impl IchimokuNode { + #[napi(constructor)] + pub fn new( + tenkan_period: u32, + kijun_period: u32, + senkou_b_period: u32, + displacement: u32, + ) -> napi::Result { + Ok(Self { + inner: wc::Ichimoku::new( + tenkan_period as usize, + kijun_period as usize, + senkou_b_period as usize, + displacement as usize, + ) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(high, low, close, 0.0)?) + .map(|o| IchimokuValue { + tenkan: o.tenkan.unwrap_or(f64::NAN), + kijun: o.kijun.unwrap_or(f64::NAN), + senkou_a: o.senkou_a.unwrap_or(f64::NAN), + senkou_b: o.senkou_b.unwrap_or(f64::NAN), + chikou: o.chikou.unwrap_or(f64::NAN), + })) + } + /// Returns `[tenkan0, kijun0, senkouA0, senkouB0, chikou0, tenkan1, ...]`, + /// length `5 * n`. Cells without a defined value are `NaN`. + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "high, low, close must be equal length".to_string(), + )); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 5]; + for i in 0..n { + if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) { + 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(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Heikin-Ashi ============================== + +#[napi(object)] +pub struct HeikinAshiValue { + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, +} + +#[napi(js_name = "HeikinAshi")] +pub struct HeikinAshiNode { + inner: wc::HeikinAshi, +} + +impl Default for HeikinAshiNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl HeikinAshiNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::HeikinAshi::new(), + } + } + #[napi] + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> napi::Result> { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(self.inner.update(c).map(|o| HeikinAshiValue { + open: o.open, + high: o.high, + low: o.low, + close: o.close, + })) + } + /// Returns `[open0, high0, low0, close0, open1, ...]`, length `4 * n`. + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(NapiError::from_reason( + "open, high, low, close must be equal length".to_string(), + )); + } + let n = open.len(); + let mut out = vec![f64::NAN; n * 4]; + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * 4] = o.open; + out[i * 4 + 1] = o.high; + out[i * 4 + 2] = o.low; + out[i * 4 + 3] = o.close; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 04ef96a0..8bb7ae82 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -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", ] diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 2f655880..1720aec5 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -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 { + 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, + Option, + Option, + Option, + Option, + )>, + > { + 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>> { + 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> { + 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>> { + 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::()?; m.add_class::()?; m.add_class::()?; + // Family 13 — Ichimoku & alternative charts + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py index 06bffe90..cb7721ce 100644 --- a/bindings/python/tests/test_input_validation.py +++ b/bindings/python/tests/test_input_validation.py @@ -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) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 324aa351..bee8fb16 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -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 diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py index 6e5f4165..95c46f3f 100644 --- a/bindings/python/tests/test_smoke.py +++ b/bindings/python/tests/test_smoke.py @@ -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 diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index d42a2464..d51ba4a5 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -5503,6 +5503,182 @@ impl WasmTdRiskLevel { } } +// ---------- Ichimoku ---------- + +#[wasm_bindgen(js_name = Ichimoku)] +pub struct WasmIchimoku { + inner: wc::Ichimoku, +} + +#[wasm_bindgen(js_class = Ichimoku)] +impl WasmIchimoku { + #[wasm_bindgen(constructor)] + pub fn new( + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, + ) -> Result { + Ok(Self { + inner: wc::Ichimoku::new(tenkan_period, kijun_period, senkou_b_period, displacement) + .map_err(map_err)?, + }) + } + /// Streaming update. Returns `{ tenkan, kijun, senkouA, senkouB, chikou }` + /// with `NaN` for any line that is not yet defined. + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result { + let c = make_candle(high, low, close, 0.0)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"tenkan".into(), &o.tenkan.unwrap_or(f64::NAN).into()).ok(); + Reflect::set(&obj, &"kijun".into(), &o.kijun.unwrap_or(f64::NAN).into()).ok(); + Reflect::set( + &obj, + &"senkouA".into(), + &o.senkou_a.unwrap_or(f64::NAN).into(), + ) + .ok(); + Reflect::set( + &obj, + &"senkouB".into(), + &o.senkou_b.unwrap_or(f64::NAN).into(), + ) + .ok(); + Reflect::set(&obj, &"chikou".into(), &o.chikou.unwrap_or(f64::NAN).into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[tenkan0, kijun0, senkouA0, senkouB0, chikou0, tenkan1, ...]`, + /// length `5 * n`. Cells without a defined value are `NaN`. + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("high, low, close must be equal length")); + } + let n = high.len(); + let mut out = vec![f64::NAN; n * 5]; + for i in 0..n { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + if let Some(o) = self.inner.update(c) { + 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(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ---------- Heikin-Ashi ---------- + +#[wasm_bindgen(js_name = HeikinAshi)] +pub struct WasmHeikinAshi { + inner: wc::HeikinAshi, +} + +impl Default for WasmHeikinAshi { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = HeikinAshi)] +impl WasmHeikinAshi { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmHeikinAshi { + Self { + inner: wc::HeikinAshi::new(), + } + } + /// Streaming update. Returns `{ open, high, low, close }` of the + /// Heikin-Ashi candle. + pub fn update( + &mut self, + open: f64, + high: f64, + low: f64, + close: f64, + ) -> Result { + let c = wc::Candle::new(open, high, low, close, 0.0, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"open".into(), &o.open.into()).ok(); + Reflect::set(&obj, &"high".into(), &o.high.into()).ok(); + Reflect::set(&obj, &"low".into(), &o.low.into()).ok(); + Reflect::set(&obj, &"close".into(), &o.close.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[open0, high0, low0, close0, open1, ...]`, length `4 * n`. + pub fn batch( + &mut self, + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + ) -> Result { + if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() { + return Err(JsError::new("open, high, low, close must be equal length")); + } + let n = open.len(); + let mut out = vec![f64::NAN; n * 4]; + for i in 0..n { + let c = wc::Candle::new(open[i], high[i], low[i], close[i], 0.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * 4] = o.open; + out[i * 4 + 1] = o.high; + out[i * 4 + 2] = o.low; + out[i * 4 + 3] = o.close; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/wickra-core/src/indicators/heikin_ashi.rs b/crates/wickra-core/src/indicators/heikin_ashi.rs new file mode 100644 index 00000000..ded483d8 --- /dev/null +++ b/crates/wickra-core/src/indicators/heikin_ashi.rs @@ -0,0 +1,198 @@ +//! Heikin-Ashi candle transform. +#![allow(clippy::manual_midpoint)] +//! +//! Heikin-Ashi ("average bar" in Japanese) smooths an OHLC candle stream so +//! trends are easier to read at a glance. The transform is purely local except +//! that `ha_open` depends on the *previous* Heikin-Ashi candle, so it remains +//! a streaming O(1) state machine. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// One Heikin-Ashi candle. +/// +/// Fields use the same names as the source `Candle` but represent the +/// transformed OHLC. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HeikinAshiOutput { + /// Heikin-Ashi open: midpoint of the previous Heikin-Ashi open and close. + pub open: f64, + /// Heikin-Ashi high: `max(real high, ha_open, ha_close)`. + pub high: f64, + /// Heikin-Ashi low: `min(real low, ha_open, ha_close)`. + pub low: f64, + /// Heikin-Ashi close: average of the real open, high, low, close. + pub close: f64, +} + +/// Streaming Heikin-Ashi transform. +/// +/// Emits a [`HeikinAshiOutput`] for every input bar starting with the very +/// first, so `warmup_period` is 1 and `batch` returns `n` outputs for `n` +/// inputs. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, HeikinAshi, Indicator}; +/// +/// let mut ha = HeikinAshi::new(); +/// let c = Candle::new(10.0, 11.0, 9.0, 10.5, 0.0, 0).unwrap(); +/// let out = ha.update(c).unwrap(); +/// // First bar: ha_open = (open + close) / 2 = 10.25. +/// assert!((out.open - 10.25).abs() < 1e-12); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HeikinAshi { + prev: Option, +} + +impl HeikinAshi { + /// Construct a fresh transform with no prior state. + #[must_use] + pub const fn new() -> Self { + Self { prev: None } + } + + /// Most recently emitted Heikin-Ashi candle, if any. + pub const fn value(&self) -> Option { + self.prev + } +} + +impl Indicator for HeikinAshi { + type Input = Candle; + type Output = HeikinAshiOutput; + + fn update(&mut self, candle: Candle) -> Option { + let ha_close = (candle.open + candle.high + candle.low + candle.close) / 4.0; + let ha_open = match self.prev { + Some(p) => f64::midpoint(p.open, p.close), + // Seed: average of the real open and close. + None => f64::midpoint(candle.open, candle.close), + }; + let ha_high = candle.high.max(ha_open).max(ha_close); + let ha_low = candle.low.min(ha_open).min(ha_close); + let out = HeikinAshiOutput { + open: ha_open, + high: ha_high, + low: ha_low, + close: ha_close, + }; + self.prev = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.prev = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.prev.is_some() + } + + fn name(&self) -> &'static str { + "HeikinAshi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn cnd(o: f64, h: f64, l: f64, c: f64) -> Candle { + Candle::new(o, h, l, c, 0.0, 0).unwrap() + } + + #[test] + fn first_bar_seeds_open_from_real_open_close() { + let mut ha = HeikinAshi::new(); + let out = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap(); + assert_relative_eq!(out.open, (10.0 + 11.0) / 2.0, epsilon = 1e-12); + assert_relative_eq!(out.close, (10.0 + 12.0 + 9.0 + 11.0) / 4.0, epsilon = 1e-12); + // high/low must envelope ha_open & ha_close along with the real H/L. + assert!(out.high >= out.open); + assert!(out.high >= out.close); + assert!(out.low <= out.open); + assert!(out.low <= out.close); + } + + #[test] + fn second_bar_uses_previous_ha_midpoint_as_open() { + let mut ha = HeikinAshi::new(); + let first = ha.update(cnd(10.0, 12.0, 9.0, 11.0)).unwrap(); + let second = ha.update(cnd(11.5, 13.0, 10.5, 12.0)).unwrap(); + assert_relative_eq!( + second.open, + (first.open + first.close) / 2.0, + epsilon = 1e-12 + ); + assert_relative_eq!( + second.close, + (11.5 + 13.0 + 10.5 + 12.0) / 4.0, + epsilon = 1e-12 + ); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| { + let p = 100.0 + f64::from(i); + cnd(p, p + 1.5, p - 1.5, p + 0.5) + }) + .collect(); + let mut a = HeikinAshi::new(); + let mut b = HeikinAshi::new(); + let batched = a.batch(&candles); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batched, streamed); + } + + #[test] + fn ready_after_first_update() { + let mut ha = HeikinAshi::new(); + assert!(!ha.is_ready()); + ha.update(cnd(10.0, 11.0, 9.0, 10.5)); + assert!(ha.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut ha = HeikinAshi::new(); + ha.update(cnd(10.0, 11.0, 9.0, 10.5)); + assert!(ha.is_ready()); + ha.reset(); + assert!(!ha.is_ready()); + assert!(ha.value().is_none()); + // After reset, the next bar re-seeds from real open/close. + let out = ha.update(cnd(20.0, 22.0, 18.0, 21.0)).unwrap(); + assert_relative_eq!(out.open, (20.0 + 21.0) / 2.0, epsilon = 1e-12); + } + + #[test] + fn metadata() { + let ha = HeikinAshi::new(); + assert_eq!(ha.warmup_period(), 1); + assert_eq!(ha.name(), "HeikinAshi"); + } + + #[test] + fn high_envelopes_open_and_close() { + // Real high below the synthetic ha_open/close still inflates ha_high. + let mut ha = HeikinAshi::new(); + // Bar 1 sets a baseline. + ha.update(cnd(100.0, 101.0, 99.0, 100.5)); + // Bar 2 with an extreme close — ha_close = (50+50+50+200)/4 = 87.5, + // ha_open = midpoint of prev open/close — and a real high of 200. + let out = ha.update(cnd(50.0, 200.0, 50.0, 200.0)).unwrap(); + assert_eq!(out.high, 200.0); + assert!(out.low <= out.open.min(out.close)); + } +} diff --git a/crates/wickra-core/src/indicators/ichimoku.rs b/crates/wickra-core/src/indicators/ichimoku.rs new file mode 100644 index 00000000..cf5261ed --- /dev/null +++ b/crates/wickra-core/src/indicators/ichimoku.rs @@ -0,0 +1,434 @@ +//! Ichimoku Kinko Hyo — the five-line cloud chart. +//! +//! The Ichimoku system bundles five distinct lines computed from highs, lows +//! and closes: +//! +//! - **Tenkan-sen** (Conversion Line): midpoint of the last `tenkan_period` +//! highs and lows (default 9). +//! - **Kijun-sen** (Base Line): midpoint over `kijun_period` (default 26). +//! - **Senkou Span A** (Leading A): `(tenkan + kijun) / 2`, shifted *forward* +//! `displacement` bars. +//! - **Senkou Span B** (Leading B): midpoint over `senkou_b_period` (default +//! 52), also shifted forward `displacement` bars. +//! - **Chikou Span** (Lagging Span): the current close, displayed `displacement` +//! bars *backwards*. +//! +//! The two Senkou Spans form the **Kumo** (cloud). At step *n* the visible +//! Senkou A/B are computed from data at step *n − displacement*; the visible +//! Chikou is the close from step *n + displacement* in a chart, but in a +//! streaming setting the only Chikou we can emit at step *n* is the close from +//! *n − displacement*. That convention matches every TA library that processes +//! candles in chronological order. + +#![allow(clippy::too_many_arguments)] + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// All five Ichimoku lines at one step. +/// +/// `tenkan` and `kijun` reflect data up to and including the current bar. +/// `senkou_a` / `senkou_b` are the leading-span values *visible at the current +/// bar*, computed from `displacement` bars ago. `chikou` is the close from +/// `displacement` bars ago (its "lagging" placement on charts). +/// +/// Any field that is not yet defined (insufficient history) is `None`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct IchimokuOutput { + /// Tenkan-sen — midpoint of the last `tenkan_period` highs/lows. + pub tenkan: Option, + /// Kijun-sen — midpoint of the last `kijun_period` highs/lows. + pub kijun: Option, + /// Senkou Span A as visible at the current bar (computed from + /// `(tenkan + kijun) / 2` at step `n - displacement`). + pub senkou_a: Option, + /// Senkou Span B as visible at the current bar (computed from the + /// `senkou_b_period` midpoint at step `n - displacement`). + pub senkou_b: Option, + /// Chikou Span — the close from `displacement` bars ago. + pub chikou: Option, +} + +/// Ichimoku Kinko Hyo indicator. +/// +/// Standard parameters are `(9, 26, 52, 26)`. The first fully-populated output +/// (every field `Some`) appears after `senkou_b_period + displacement - 1` +/// candles — 77 bars at the defaults — because Senkou B needs its own 52-bar +/// midpoint *and* a 26-bar history of those midpoints to displace from. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Ichimoku, Indicator}; +/// +/// let mut ichi = Ichimoku::classic(); +/// for i in 0..120 { +/// let p = 100.0 + f64::from(i); +/// let candle = Candle::new(p, p + 2.0, p - 2.0, p + 1.0, 0.0, i64::from(i)).unwrap(); +/// ichi.update(candle); +/// } +/// let out = ichi.value().unwrap(); +/// assert!(out.tenkan.is_some() && out.kijun.is_some()); +/// assert!(out.senkou_a.is_some() && out.senkou_b.is_some()); +/// assert!(out.chikou.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ichimoku { + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, + // Rolling window of recent highs/lows for the longest lookback we need. + highs: VecDeque, + lows: VecDeque, + // Past (tenkan+kijun)/2 values used to emit the displaced Senkou A. + senkou_a_history: VecDeque, + // Past Senkou B midpoint values used to emit the displaced Senkou B. + senkou_b_history: VecDeque, + // Past closes for the lagging Chikou span. + close_history: VecDeque, + last: Option, +} + +impl Ichimoku { + /// Construct an Ichimoku indicator with custom periods. + /// + /// `tenkan_period` is the short midpoint window (default 9), `kijun_period` + /// the medium (default 26), `senkou_b_period` the long (default 52), and + /// `displacement` the forward/backward shift in bars (default 26). + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any of `tenkan_period`, `kijun_period`, + /// `senkou_b_period`, or `displacement` is zero, and [`Error::InvalidPeriod`] + /// if the periods are not in strictly increasing order + /// (`tenkan < kijun < senkou_b`). + pub fn new( + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, + ) -> Result { + if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 || displacement == 0 { + return Err(Error::PeriodZero); + } + if tenkan_period >= kijun_period || kijun_period >= senkou_b_period { + return Err(Error::InvalidPeriod { + message: "Ichimoku periods must satisfy tenkan < kijun < senkou_b", + }); + } + let cap = senkou_b_period; + Ok(Self { + tenkan_period, + kijun_period, + senkou_b_period, + displacement, + highs: VecDeque::with_capacity(cap), + lows: VecDeque::with_capacity(cap), + senkou_a_history: VecDeque::with_capacity(displacement), + senkou_b_history: VecDeque::with_capacity(displacement), + close_history: VecDeque::with_capacity(displacement), + last: None, + }) + } + + /// Classical `(9, 26, 52, 26)` configuration. + pub fn classic() -> Self { + Self::new(9, 26, 52, 26).expect("classic Ichimoku periods are valid") + } + + /// Configured periods as `(tenkan, kijun, senkou_b, displacement)`. + pub const fn periods(&self) -> (usize, usize, usize, usize) { + ( + self.tenkan_period, + self.kijun_period, + self.senkou_b_period, + self.displacement, + ) + } + + /// Most recent output if at least one bar has been consumed. + pub const fn value(&self) -> Option { + self.last + } + + /// Midpoint of the last `n` highs/lows. Assumes `self.highs.len() >= n` + /// (the caller checks). + fn midpoint(&self, n: usize) -> f64 { + let len = self.highs.len(); + let start = len - n; + let mut hi = f64::NEG_INFINITY; + let mut lo = f64::INFINITY; + for i in start..len { + hi = hi.max(self.highs[i]); + lo = lo.min(self.lows[i]); + } + f64::midpoint(hi, lo) + } +} + +impl Indicator for Ichimoku { + type Input = Candle; + type Output = IchimokuOutput; + + fn update(&mut self, candle: Candle) -> Option { + // Ring-buffer the new bar; cap at the longest lookback. + if self.highs.len() == self.senkou_b_period { + self.highs.pop_front(); + self.lows.pop_front(); + } + self.highs.push_back(candle.high); + self.lows.push_back(candle.low); + + let tenkan = + (self.highs.len() >= self.tenkan_period).then(|| self.midpoint(self.tenkan_period)); + let kijun = + (self.highs.len() >= self.kijun_period).then(|| self.midpoint(self.kijun_period)); + let senkou_b_now = + (self.highs.len() >= self.senkou_b_period).then(|| self.midpoint(self.senkou_b_period)); + + // Today's contribution to the leading spans (will become visible after + // `displacement` more bars). + let senkou_a_now = match (tenkan, kijun) { + (Some(t), Some(k)) => Some(f64::midpoint(t, k)), + _ => None, + }; + + // The currently-visible Senkou A/B at this bar are the values that were + // computed `displacement` bars ago. We always push the freshly-computed + // `senkou_a_now` / `senkou_b_now` to keep the history aligned 1:1 with + // bars; NaN encodes "no value yet" so the buffer indices stay simple. + let push_or_nan = |q: &mut VecDeque, v: Option, cap: usize| { + if q.len() == cap { + q.pop_front(); + } + q.push_back(v.unwrap_or(f64::NAN)); + }; + push_or_nan(&mut self.senkou_a_history, senkou_a_now, self.displacement); + push_or_nan(&mut self.senkou_b_history, senkou_b_now, self.displacement); + + // The visible Senkou A/B at the current bar were buffered exactly + // `displacement` updates ago, which is `self.senkou_*_history.front()` + // once the buffer is full. + let take_front = |q: &VecDeque, cap: usize| -> Option { + if q.len() == cap { + let v = q[0]; + if v.is_nan() { + None + } else { + Some(v) + } + } else { + None + } + }; + let senkou_a = take_front(&self.senkou_a_history, self.displacement); + let senkou_b = take_front(&self.senkou_b_history, self.displacement); + + // Chikou: close from `displacement` bars ago. + if self.close_history.len() == self.displacement { + self.close_history.pop_front(); + } + self.close_history.push_back(candle.close); + let chikou = (self.close_history.len() == self.displacement).then(|| self.close_history[0]); + + let out = IchimokuOutput { + tenkan, + kijun, + senkou_a, + senkou_b, + chikou, + }; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.highs.clear(); + self.lows.clear(); + self.senkou_a_history.clear(); + self.senkou_b_history.clear(); + self.close_history.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + // First fully-populated row needs senkou_b's midpoint to have travelled + // `displacement` bars forward. + self.senkou_b_period + self.displacement - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some_and(|o| { + o.tenkan.is_some() + && o.kijun.is_some() + && o.senkou_a.is_some() + && o.senkou_b.is_some() + && o.chikou.is_some() + }) + } + + fn name(&self) -> &'static str { + "Ichimoku" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(h: f64, l: f64, cl: f64, i: i64) -> Candle { + Candle::new(cl, h, l, cl, 0.0, i).unwrap() + } + + fn ramp(n: i64) -> Vec { + (0..n) + .map(|i| { + let p = 100.0 + f64::from(i32::try_from(i).unwrap()); + c(p + 2.0, p - 2.0, p + 1.0, i) + }) + .collect() + } + + #[test] + fn rejects_zero_periods() { + assert!(matches!( + Ichimoku::new(0, 26, 52, 26), + Err(Error::PeriodZero) + )); + assert!(matches!( + Ichimoku::new(9, 0, 52, 26), + Err(Error::PeriodZero) + )); + assert!(matches!( + Ichimoku::new(9, 26, 0, 26), + Err(Error::PeriodZero) + )); + assert!(matches!( + Ichimoku::new(9, 26, 52, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn rejects_non_increasing_periods() { + assert!(matches!( + Ichimoku::new(26, 26, 52, 26), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Ichimoku::new(9, 52, 52, 26), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + Ichimoku::new(52, 26, 9, 26), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let ichi = Ichimoku::classic(); + assert_eq!(ichi.periods(), (9, 26, 52, 26)); + assert_eq!(ichi.warmup_period(), 77); + assert_eq!(ichi.name(), "Ichimoku"); + assert!(ichi.value().is_none()); + } + + #[test] + fn tenkan_emits_at_period() { + let mut ichi = Ichimoku::classic(); + let candles = ramp(10); + let out = ichi.batch(&candles); + // The 9th update is the first time tenkan has 9 highs/lows. + for (i, o) in out.iter().enumerate() { + let v = o.unwrap(); + if i < 8 { + assert!(v.tenkan.is_none(), "tenkan must be None until 9 bars"); + } else { + assert!(v.tenkan.is_some(), "tenkan must be Some from bar 9 on"); + } + } + } + + #[test] + fn fully_populated_after_warmup() { + let mut ichi = Ichimoku::classic(); + let candles = ramp(120); + let out = ichi.batch(&candles); + let last = out.last().unwrap().unwrap(); + assert!(last.tenkan.is_some()); + assert!(last.kijun.is_some()); + assert!(last.senkou_a.is_some()); + assert!(last.senkou_b.is_some()); + assert!(last.chikou.is_some()); + assert!(ichi.is_ready()); + } + + #[test] + fn ramp_tenkan_equals_window_midpoint() { + // On a strict ramp the midpoint of the last 9 (high, low) candles is + // the midpoint of the first and last bar in that window. + let mut ichi = Ichimoku::classic(); + let candles = ramp(20); + let out = ichi.batch(&candles); + // At index 8 (9th bar), the window is bars 0..=8 with highs 102..110 + // and lows 98..106. Midpoint = (110 + 98) / 2 = 104. + let v = out[8].unwrap(); + assert_relative_eq!(v.tenkan.unwrap(), 104.0, epsilon = 1e-12); + } + + #[test] + fn chikou_is_close_displacement_bars_back() { + let mut ichi = Ichimoku::classic(); + let candles = ramp(60); + let out = ichi.batch(&candles); + // Displacement = 26; at bar index 25, chikou is the close from bar 0. + let v = out[25].unwrap(); + assert_relative_eq!(v.chikou.unwrap(), candles[0].close, epsilon = 1e-12); + let v = out[50].unwrap(); + assert_relative_eq!(v.chikou.unwrap(), candles[25].close, epsilon = 1e-12); + } + + #[test] + fn batch_equals_streaming() { + let candles = ramp(120); + let mut a = Ichimoku::classic(); + let mut b = Ichimoku::classic(); + let batched = a.batch(&candles); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batched.len(), streamed.len()); + for (lhs, rhs) in batched.iter().zip(streamed.iter()) { + let (l, r) = (lhs.unwrap(), rhs.unwrap()); + assert_eq!(l.tenkan, r.tenkan); + assert_eq!(l.kijun, r.kijun); + assert_eq!(l.senkou_a, r.senkou_a); + assert_eq!(l.senkou_b, r.senkou_b); + assert_eq!(l.chikou, r.chikou); + } + } + + #[test] + fn reset_clears_state() { + let mut ichi = Ichimoku::classic(); + ichi.batch(&ramp(100)); + assert!(ichi.is_ready()); + ichi.reset(); + assert!(!ichi.is_ready()); + assert!(ichi.value().is_none()); + } + + #[test] + fn custom_periods_accepted() { + let mut ichi = Ichimoku::new(5, 10, 20, 10).unwrap(); + let out = ichi.batch(&ramp(40)); + let last = out.last().unwrap().unwrap(); + assert!(last.tenkan.is_some()); + assert!(last.senkou_a.is_some()); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 75b336c4..e6b2b75d 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -62,11 +62,13 @@ mod force_index; mod fractal_chaos_bands; mod frama; mod garman_klass; +mod heikin_ashi; mod hilbert_dominant_cycle; mod hilo_activator; mod historical_volatility; mod hma; mod hurst_channel; +mod ichimoku; mod inertia; mod instantaneous_trendline; mod inverse_fisher_transform; @@ -226,11 +228,13 @@ pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; pub use frama::Frama; pub use garman_klass::GarmanKlassVolatility; +pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput}; pub use hilbert_dominant_cycle::HilbertDominantCycle; pub use hilo_activator::HiLoActivator; pub use historical_volatility::HistoricalVolatility; pub use hma::Hma; pub use hurst_channel::{HurstChannel, HurstChannelOutput}; +pub use ichimoku::{Ichimoku, IchimokuOutput}; pub use inertia::Inertia; pub use instantaneous_trendline::InstantaneousTrendline; pub use inverse_fisher_transform::InverseFisherTransform; diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 1809c5a1..a224a179 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -56,9 +56,10 @@ pub use indicators::{ DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput, FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, - Frama, GarmanKlassVolatility, HiLoActivator, HilbertDominantCycle, HistoricalVolatility, Hma, - HurstChannel, HurstChannelOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma, - Kama, Keltner, KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, + Frama, GarmanKlassVolatility, HeikinAshi, HeikinAshiOutput, HiLoActivator, + HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, Ichimoku, + IchimokuOutput, Inertia, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, Keltner, + KeltnerOutput, Kst, KstOutput, Kvo, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, MassIndex, McGinleyDynamic, MedianPrice, Mfi, Mom, Natr, Nvi, Obv, ParkinsonVolatility, PercentB, diff --git a/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 5d258aec..b150384a 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -23,16 +23,16 @@ use wickra::{ BatchExt, BollingerBands, Camarilla, Candle, CenterOfGravity, ClassicPivots, CyberneticCycle, Decycler, DecyclerOscillator, DemandIndex, DemarkPivots, DonchianStop, DoubleBollinger, EhlersStochastic, Ema, EmpiricalModeDecomposition, Fama, FibonacciPivots, FisherTransform, - FractalChaosBands, Frama, GarmanKlassVolatility, HiLoActivator, HilbertDominantCycle, - HurstChannel, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kst, Kvo, - LinRegChannel, MaEnvelope, MacdIndicator, Mama, MarketFacilitationIndex, McGinleyDynamic, Nvi, - Obv, ParkinsonVolatility, PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, - RogersSatchellVolatility, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, SineWave, Sma, - StandardErrorBands, StarcBands, StepTrailingStop, Stochastic, SuperSmoother, TdCombo, - TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, - TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, Vidya, VoltyStop, VolumeOscillator, - VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, WoodiePivots, YangZhangVolatility, - YoyoExit, ZigZag, + FractalChaosBands, Frama, GarmanKlassVolatility, HeikinAshi, HiLoActivator, + HilbertDominantCycle, HurstChannel, Ichimoku, Indicator, InstantaneousTrendline, + InverseFisherTransform, Jma, Kst, Kvo, LinRegChannel, MaEnvelope, MacdIndicator, Mama, + MarketFacilitationIndex, McGinleyDynamic, Nvi, Obv, ParkinsonVolatility, + PercentageTrailingStop, Pgo, Pvi, RenkoTrailingStop, RogersSatchellVolatility, RoofingFilter, + Rsi, Rvi, RviVolatility, Rwi, SineWave, Sma, StandardErrorBands, StarcBands, StepTrailingStop, + Stochastic, SuperSmoother, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, + TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, Tii, Tsv, TtmSqueeze, + Vidya, VoltyStop, VolumeOscillator, VwapStdDevBands, Vzo, WaveTrend, WilliamsFractals, Wma, + WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, }; use wickra_data::csv::CandleReader; @@ -187,6 +187,8 @@ fn benches(c: &mut Criterion) { bench_candle_input(c, "wave_trend", &candles, || WaveTrend::classic().unwrap()); bench_candle_input(c, "stochastic", &candles, Stochastic::classic); bench_candle_input(c, "obv", &candles, Obv::new); + bench_candle_input(c, "ichimoku", &candles, Ichimoku::classic); + bench_candle_input(c, "heikin_ashi", &candles, HeikinAshi::new); // Family 10 — Ehlers / Cycle scalar benchmarks. bench_scalar(c, "super_smoother", &closes, || { diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index 24a4973e..a046fef6 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -25,17 +25,18 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, - AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci, ChaikinMoneyFlow, - ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, - ClassicPivots, DemandIndex, DemarkPivots, Donchian, DonchianStop, EaseOfMovement, Evwma, - FibonacciPivots, ForceIndex, FractalChaosBands, GarmanKlassVolatility, HiLoActivator, - HurstChannel, Indicator, Inertia, Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, - Mfi, Natr, Nvi, Obv, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, - Rvi, Rwi, Smi, StarcBands, Stochastic, SuperTrend, TdCombo, TdCountdown, TdDeMarker, - TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, - TdSequential, TdSetup, TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VoltyStop, - VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, - WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, + AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, Camarilla, Candle, Cci, + ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, + ChoppinessIndex, ClassicPivots, DemandIndex, DemarkPivots, Donchian, DonchianStop, + EaseOfMovement, Evwma, FibonacciPivots, ForceIndex, FractalChaosBands, + GarmanKlassVolatility, HeikinAshi, HiLoActivator, HurstChannel, Ichimoku, Indicator, + Inertia, Keltner, Kvo, MarketFacilitationIndex, MassIndex, MedianPrice, Mfi, Natr, Nvi, + Obv, ParkinsonVolatility, Pgo, Psar, Pvi, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, + Smi, StarcBands, Stochastic, SuperTrend, TdCombo, TdCountdown, TdDeMarker, TdDifferential, + TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, + TrueRange, Tsv, TtmSqueeze, TypicalPrice, UltimateOscillator, VoltyStop, VolumeOscillator, + VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, + WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag, }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -165,6 +166,24 @@ fuzz_target!(|data: Vec| { let _ = Stochastic::new(14, 3).unwrap().batch(&candles); } + // --- Ichimoku (5 lines, hand-rolled because of multi-Option output) --- + { + let mut ichi = Ichimoku::classic(); + for c in &candles { + let _ = ichi.update(*c); + } + let _ = Ichimoku::classic().batch(&candles); + } + + // --- Heikin-Ashi (4-field candle transform) --- + { + let mut ha = HeikinAshi::new(); + for c in &candles { + let _ = ha.update(*c); + } + let _ = HeikinAshi::new().batch(&candles); + } + // --- DeMark family --- drive(|| TdSetup::new(4, 9).unwrap(), &candles); drive(|| TdDeMarker::new(14).unwrap(), &candles);