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:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Option<IchimokuValue>> {
|
||||
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<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
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<Option<HeikinAshiValue>> {
|
||||
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<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<WasmIchimoku, JsError> {
|
||||
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<JsValue, JsError> {
|
||||
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<Float64Array, JsError> {
|
||||
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<JsValue, JsError> {
|
||||
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<Float64Array, JsError> {
|
||||
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::*;
|
||||
|
||||
@@ -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<HeikinAshiOutput>,
|
||||
}
|
||||
|
||||
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<HeikinAshiOutput> {
|
||||
self.prev
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeikinAshi {
|
||||
type Input = Candle;
|
||||
type Output = HeikinAshiOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<HeikinAshiOutput> {
|
||||
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<Candle> = (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));
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
/// Kijun-sen — midpoint of the last `kijun_period` highs/lows.
|
||||
pub kijun: Option<f64>,
|
||||
/// Senkou Span A as visible at the current bar (computed from
|
||||
/// `(tenkan + kijun) / 2` at step `n - displacement`).
|
||||
pub senkou_a: Option<f64>,
|
||||
/// Senkou Span B as visible at the current bar (computed from the
|
||||
/// `senkou_b_period` midpoint at step `n - displacement`).
|
||||
pub senkou_b: Option<f64>,
|
||||
/// Chikou Span — the close from `displacement` bars ago.
|
||||
pub chikou: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
lows: VecDeque<f64>,
|
||||
// Past (tenkan+kijun)/2 values used to emit the displaced Senkou A.
|
||||
senkou_a_history: VecDeque<f64>,
|
||||
// Past Senkou B midpoint values used to emit the displaced Senkou B.
|
||||
senkou_b_history: VecDeque<f64>,
|
||||
// Past closes for the lagging Chikou span.
|
||||
close_history: VecDeque<f64>,
|
||||
last: Option<IchimokuOutput>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<IchimokuOutput> {
|
||||
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<IchimokuOutput> {
|
||||
// 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<f64>, v: Option<f64>, 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<f64>, cap: usize| -> Option<f64> {
|
||||
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<Candle> {
|
||||
(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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, || {
|
||||
|
||||
@@ -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<Candle>` by chunking it into
|
||||
@@ -165,6 +166,24 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user