Skip to main content

wickra_core/indicators/
wave_trend.rs

1//! Wave Trend Oscillator (`LazyBear`).
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::sma::Sma;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Wave Trend Oscillator output: the two lines `wt1` (the oscillator) and
10/// `wt2` (the signal SMA).
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct WaveTrendOutput {
13    /// `wt1` — the smoothed channel index.
14    pub wt1: f64,
15    /// `wt2` — the SMA-smoothed signal line.
16    pub wt2: f64,
17}
18
19/// `LazyBear`'s Wave Trend Oscillator — a two-line momentum gauge built from
20/// the typical price and three cascaded EMAs.
21///
22/// For each candle let `ap_t = (high + low + close) / 3`:
23///
24/// ```text
25/// esa_t = EMA(ap, channel_period)
26/// d_t   = EMA(|ap − esa|, channel_period)
27/// ci_t  = (ap_t − esa_t) / (0.015 * d_t)
28/// wt1_t = EMA(ci, average_period)
29/// wt2_t = SMA(wt1, signal_period)
30/// ```
31///
32/// Bullish trigger: `wt1` crossing above `wt2` from an oversold region
33/// (typically `wt1 < -60`); bearish trigger: the mirror crossover above
34/// `+60`. The indicator is mean-reverting around zero, so it is most useful
35/// at extremes.
36///
37/// The canonical `LazyBear` defaults are
38/// `(channel_period = 10, average_period = 21, signal_period = 4)`; warmup is
39/// `channel_period + average_period + signal_period − 2`.
40///
41/// Non-finite `d` (a zero-volatility seed where the absolute-deviation EMA
42/// has not yet recorded any movement) collapses the channel index to zero.
43///
44/// # Example
45///
46/// ```
47/// use wickra_core::{Candle, Indicator, WaveTrend};
48///
49/// let mut indicator = WaveTrend::classic().unwrap();
50/// let mut last = None;
51/// for i in 0..80 {
52///     let base = 100.0 + f64::from(i);
53///     let candle =
54///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
55///     last = indicator.update(candle);
56/// }
57/// assert!(last.is_some());
58/// ```
59#[derive(Debug, Clone)]
60pub struct WaveTrend {
61    channel_period: usize,
62    average_period: usize,
63    signal_period: usize,
64    esa: Ema,
65    dev_ema: Ema,
66    tci: Ema,
67    signal: Sma,
68    last: Option<WaveTrendOutput>,
69}
70
71impl WaveTrend {
72    /// Construct a new Wave Trend Oscillator with explicit periods.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`Error::PeriodZero`] if any period is `0`.
77    pub fn new(channel_period: usize, average_period: usize, signal_period: usize) -> Result<Self> {
78        if channel_period == 0 || average_period == 0 || signal_period == 0 {
79            return Err(Error::PeriodZero);
80        }
81        Ok(Self {
82            channel_period,
83            average_period,
84            signal_period,
85            esa: Ema::new(channel_period)?,
86            dev_ema: Ema::new(channel_period)?,
87            tci: Ema::new(average_period)?,
88            signal: Sma::new(signal_period)?,
89            last: None,
90        })
91    }
92
93    /// `LazyBear`'s classic Wave Trend: `(channel = 10, average = 21, signal = 4)`.
94    ///
95    /// # Errors
96    ///
97    /// None in practice — all periods are non-zero.
98    pub fn classic() -> Result<Self> {
99        Self::new(10, 21, 4)
100    }
101
102    /// Configured `(channel_period, average_period, signal_period)`.
103    pub const fn periods(&self) -> (usize, usize, usize) {
104        (self.channel_period, self.average_period, self.signal_period)
105    }
106
107    /// Current value if available.
108    pub const fn value(&self) -> Option<WaveTrendOutput> {
109        self.last
110    }
111}
112
113impl Indicator for WaveTrend {
114    type Input = Candle;
115    type Output = WaveTrendOutput;
116
117    #[inline]
118    fn update(&mut self, candle: Candle) -> Option<WaveTrendOutput> {
119        let ap = (candle.high + candle.low + candle.close) / 3.0;
120
121        // Stage 1: ESA = EMA(ap, channel_period). Must be ready before we
122        // can compute the absolute deviation EMA against it.
123        let esa = self.esa.update(ap)?;
124
125        // Stage 2: deviation EMA tracks |ap - esa|.
126        let d = self.dev_ema.update((ap - esa).abs())?;
127
128        // Stage 3: channel index. On a perfectly flat market `(ap - esa)`
129        // and `d` are both within an ULP or two of zero; their ratio is
130        // mathematically indeterminate and would otherwise produce garbage
131        // like `-66.67 = -1 / 0.015`. Treat any sub-ULP deviation as zero,
132        // matching pandas-ta's flat-market behaviour. The threshold scales
133        // with `esa` so it adapts to any price magnitude.
134        let flat_tol = esa.abs().max(1.0) * 16.0 * f64::EPSILON;
135        let ci = if d <= flat_tol {
136            0.0
137        } else {
138            (ap - esa) / (0.015 * d)
139        };
140
141        // Stage 4: wt1 = EMA(ci, average_period).
142        let wt1 = self.tci.update(ci)?;
143
144        // Stage 5: wt2 = SMA(wt1, signal_period).
145        let wt2 = self.signal.update(wt1)?;
146
147        let out = WaveTrendOutput { wt1, wt2 };
148        self.last = Some(out);
149        Some(out)
150    }
151
152    fn reset(&mut self) {
153        self.esa.reset();
154        self.dev_ema.reset();
155        self.tci.reset();
156        self.signal.reset();
157        self.last = None;
158    }
159
160    #[inline]
161    fn warmup_period(&self) -> usize {
162        // EMA(esa) first emits at input `channel_period`; the second EMA
163        // (deviation) takes its input from the same bar and emits at the
164        // same `channel_period`-th input (it can already start computing
165        // |ap - esa| as soon as esa is ready, and the EMA-of-EMA construction
166        // uses the inner EMA's first valid output as its first input —
167        // however because we gate via `?` on both stages, the second EMA's
168        // first valid input is at the channel_period-th input, then itself
169        // needs channel_period - 1 more inputs to warm... but our Ema
170        // implementation seeds via SMA on the first `period` inputs, so the
171        // dev_ema needs channel_period inputs of |ap - esa| values.
172        //
173        // Actually: esa emits at input `channel_period` (1-based). dev_ema
174        // gets fed starting at that input, and needs `channel_period` inputs
175        // of its own to first emit: at the `2 * channel_period - 1`-th input
176        // dev_ema is ready (it has consumed channel_period inputs starting
177        // from the channel_period-th). tci then needs `average_period`
178        // inputs of `ci`, so it's ready at `2 * channel_period - 1 +
179        // average_period - 1`. Signal needs `signal_period` inputs of wt1
180        // → ready at `2 * channel_period - 1 + average_period - 1 +
181        // signal_period - 1` = `2 * channel_period + average_period +
182        // signal_period - 3`.
183        2 * self.channel_period + self.average_period + self.signal_period - 3
184    }
185
186    #[inline]
187    fn is_ready(&self) -> bool {
188        self.last.is_some()
189    }
190
191    #[inline]
192    fn name(&self) -> &'static str {
193        "WaveTrend"
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::traits::BatchExt;
201
202    fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
203        Candle::new(c, h, l, c, 1.0, ts).unwrap()
204    }
205
206    #[test]
207    fn rejects_zero_period() {
208        assert!(matches!(WaveTrend::new(0, 21, 4), Err(Error::PeriodZero)));
209        assert!(matches!(WaveTrend::new(10, 0, 4), Err(Error::PeriodZero)));
210        assert!(matches!(WaveTrend::new(10, 21, 0), Err(Error::PeriodZero)));
211    }
212
213    #[test]
214    fn accessors_and_metadata() {
215        let mut w = WaveTrend::classic().unwrap();
216        assert_eq!(w.periods(), (10, 21, 4));
217        assert_eq!(w.name(), "WaveTrend");
218        // 2 * 10 + 21 + 4 - 3 = 42.
219        assert_eq!(w.warmup_period(), 42);
220        assert!(w.value().is_none());
221        let candles: Vec<Candle> = (0..80_i64)
222            .map(|i| {
223                let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
224                candle(p + 1.0, p - 1.0, p, i)
225            })
226            .collect();
227        for c in &candles {
228            w.update(*c);
229        }
230        assert!(w.value().is_some());
231    }
232
233    #[test]
234    fn first_emission_at_warmup_period() {
235        let candles: Vec<Candle> = (0..60_i64)
236            .map(|i| {
237                let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0;
238                candle(p + 1.0, p - 1.0, p, i)
239            })
240            .collect();
241        let mut w = WaveTrend::new(5, 8, 3).unwrap();
242        let warmup = 2 * 5 + 8 + 3 - 3; // 18
243        assert_eq!(w.warmup_period(), warmup);
244        let out = w.batch(&candles);
245        for v in out.iter().take(warmup - 1) {
246            assert!(v.is_none());
247        }
248        assert!(out[warmup - 1].is_some());
249    }
250
251    #[test]
252    fn constant_series_yields_zero_lines() {
253        // Flat market: every ap equals esa within an ULP, so the
254        // flat-tolerance guard collapses ci to 0 and both lines remain at 0.
255        let candles: Vec<Candle> = (0..80_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
256        let mut w = WaveTrend::new(5, 8, 3).unwrap();
257        let last = w.batch(&candles).into_iter().flatten().last().unwrap();
258        assert_eq!(last.wt1, 0.0);
259        assert_eq!(last.wt2, 0.0);
260    }
261
262    #[test]
263    fn pure_uptrend_is_positive() {
264        let candles: Vec<Candle> = (0..120_i64)
265            .map(|i| {
266                let base = 100.0 + (i as f64) * 0.5;
267                candle(base + 1.0, base - 0.5, base + 0.5, i)
268            })
269            .collect();
270        let mut w = WaveTrend::classic().unwrap();
271        let last = w.batch(&candles).into_iter().flatten().last().unwrap();
272        assert!(
273            last.wt1 > 0.0,
274            "uptrend wt1 should be positive, got {}",
275            last.wt1
276        );
277        assert!(
278            last.wt2 > 0.0,
279            "uptrend wt2 should be positive, got {}",
280            last.wt2
281        );
282    }
283
284    #[test]
285    fn pure_downtrend_is_negative() {
286        let candles: Vec<Candle> = (0..120_i64)
287            .map(|i| {
288                let base = 200.0 - (i as f64) * 0.5;
289                candle(base + 1.0, base - 0.5, base - 0.5, i)
290            })
291            .collect();
292        let mut w = WaveTrend::classic().unwrap();
293        let last = w.batch(&candles).into_iter().flatten().last().unwrap();
294        assert!(last.wt1 < 0.0);
295        assert!(last.wt2 < 0.0);
296    }
297
298    #[test]
299    fn outputs_remain_finite() {
300        let candles: Vec<Candle> = (0..200_i64)
301            .map(|i| {
302                let p = 100.0 + ((i as f64) * 0.3).sin() * 8.0;
303                candle(p + 2.0, p - 2.0, p, i)
304            })
305            .collect();
306        let mut w = WaveTrend::classic().unwrap();
307        for v in w.batch(&candles).into_iter().flatten() {
308            assert!(v.wt1.is_finite() && v.wt2.is_finite());
309        }
310    }
311
312    #[test]
313    fn batch_equals_streaming() {
314        let candles: Vec<Candle> = (0..120_i64)
315            .map(|i| {
316                let p = 100.0 + ((i as f64) * 0.27).sin() * 6.0;
317                candle(p + 1.5, p - 1.5, p, i)
318            })
319            .collect();
320        let mut a = WaveTrend::classic().unwrap();
321        let mut b = WaveTrend::classic().unwrap();
322        assert_eq!(
323            a.batch(&candles),
324            candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
325        );
326    }
327
328    #[test]
329    fn reset_clears_state() {
330        let candles: Vec<Candle> = (0..80_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
331        let mut w = WaveTrend::classic().unwrap();
332        w.batch(&candles);
333        assert!(w.is_ready());
334        w.reset();
335        assert!(!w.is_ready());
336        assert_eq!(w.update(candles[0]), None);
337    }
338}