wickra-core 0.4.1

Core streaming-first technical indicators engine for the Wickra library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
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());
    }
}