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    fn update(&mut self, candle: Candle) -> Option<WaveTrendOutput> {
118        let ap = (candle.high + candle.low + candle.close) / 3.0;
119
120        // Stage 1: ESA = EMA(ap, channel_period). Must be ready before we
121        // can compute the absolute deviation EMA against it.
122        let esa = self.esa.update(ap)?;
123
124        // Stage 2: deviation EMA tracks |ap - esa|.
125        let d = self.dev_ema.update((ap - esa).abs())?;
126
127        // Stage 3: channel index. On a perfectly flat market `(ap - esa)`
128        // and `d` are both within an ULP or two of zero; their ratio is
129        // mathematically indeterminate and would otherwise produce garbage
130        // like `-66.67 = -1 / 0.015`. Treat any sub-ULP deviation as zero,
131        // matching pandas-ta's flat-market behaviour. The threshold scales
132        // with `esa` so it adapts to any price magnitude.
133        let flat_tol = esa.abs().max(1.0) * 16.0 * f64::EPSILON;
134        let ci = if d <= flat_tol {
135            0.0
136        } else {
137            (ap - esa) / (0.015 * d)
138        };
139
140        // Stage 4: wt1 = EMA(ci, average_period).
141        let wt1 = self.tci.update(ci)?;
142
143        // Stage 5: wt2 = SMA(wt1, signal_period).
144        let wt2 = self.signal.update(wt1)?;
145
146        let out = WaveTrendOutput { wt1, wt2 };
147        self.last = Some(out);
148        Some(out)
149    }
150
151    fn reset(&mut self) {
152        self.esa.reset();
153        self.dev_ema.reset();
154        self.tci.reset();
155        self.signal.reset();
156        self.last = None;
157    }
158
159    fn warmup_period(&self) -> usize {
160        // EMA(esa) first emits at input `channel_period`; the second EMA
161        // (deviation) takes its input from the same bar and emits at the
162        // same `channel_period`-th input (it can already start computing
163        // |ap - esa| as soon as esa is ready, and the EMA-of-EMA construction
164        // uses the inner EMA's first valid output as its first input —
165        // however because we gate via `?` on both stages, the second EMA's
166        // first valid input is at the channel_period-th input, then itself
167        // needs channel_period - 1 more inputs to warm... but our Ema
168        // implementation seeds via SMA on the first `period` inputs, so the
169        // dev_ema needs channel_period inputs of |ap - esa| values.
170        //
171        // Actually: esa emits at input `channel_period` (1-based). dev_ema
172        // gets fed starting at that input, and needs `channel_period` inputs
173        // of its own to first emit: at the `2 * channel_period - 1`-th input
174        // dev_ema is ready (it has consumed channel_period inputs starting
175        // from the channel_period-th). tci then needs `average_period`
176        // inputs of `ci`, so it's ready at `2 * channel_period - 1 +
177        // average_period - 1`. Signal needs `signal_period` inputs of wt1
178        // → ready at `2 * channel_period - 1 + average_period - 1 +
179        // signal_period - 1` = `2 * channel_period + average_period +
180        // signal_period - 3`.
181        2 * self.channel_period + self.average_period + self.signal_period - 3
182    }
183
184    fn is_ready(&self) -> bool {
185        self.last.is_some()
186    }
187
188    fn name(&self) -> &'static str {
189        "WaveTrend"
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::traits::BatchExt;
197
198    fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
199        Candle::new(c, h, l, c, 1.0, ts).unwrap()
200    }
201
202    #[test]
203    fn rejects_zero_period() {
204        assert!(matches!(WaveTrend::new(0, 21, 4), Err(Error::PeriodZero)));
205        assert!(matches!(WaveTrend::new(10, 0, 4), Err(Error::PeriodZero)));
206        assert!(matches!(WaveTrend::new(10, 21, 0), Err(Error::PeriodZero)));
207    }
208
209    #[test]
210    fn accessors_and_metadata() {
211        let mut w = WaveTrend::classic().unwrap();
212        assert_eq!(w.periods(), (10, 21, 4));
213        assert_eq!(w.name(), "WaveTrend");
214        // 2 * 10 + 21 + 4 - 3 = 42.
215        assert_eq!(w.warmup_period(), 42);
216        assert!(w.value().is_none());
217        let candles: Vec<Candle> = (0..80_i64)
218            .map(|i| {
219                let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
220                candle(p + 1.0, p - 1.0, p, i)
221            })
222            .collect();
223        for c in &candles {
224            w.update(*c);
225        }
226        assert!(w.value().is_some());
227    }
228
229    #[test]
230    fn first_emission_at_warmup_period() {
231        let candles: Vec<Candle> = (0..60_i64)
232            .map(|i| {
233                let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0;
234                candle(p + 1.0, p - 1.0, p, i)
235            })
236            .collect();
237        let mut w = WaveTrend::new(5, 8, 3).unwrap();
238        let warmup = 2 * 5 + 8 + 3 - 3; // 18
239        assert_eq!(w.warmup_period(), warmup);
240        let out = w.batch(&candles);
241        for v in out.iter().take(warmup - 1) {
242            assert!(v.is_none());
243        }
244        assert!(out[warmup - 1].is_some());
245    }
246
247    #[test]
248    fn constant_series_yields_zero_lines() {
249        // Flat market: every ap equals esa within an ULP, so the
250        // flat-tolerance guard collapses ci to 0 and both lines remain at 0.
251        let candles: Vec<Candle> = (0..80_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
252        let mut w = WaveTrend::new(5, 8, 3).unwrap();
253        let last = w.batch(&candles).into_iter().flatten().last().unwrap();
254        assert_eq!(last.wt1, 0.0);
255        assert_eq!(last.wt2, 0.0);
256    }
257
258    #[test]
259    fn pure_uptrend_is_positive() {
260        let candles: Vec<Candle> = (0..120_i64)
261            .map(|i| {
262                let base = 100.0 + (i as f64) * 0.5;
263                candle(base + 1.0, base - 0.5, base + 0.5, i)
264            })
265            .collect();
266        let mut w = WaveTrend::classic().unwrap();
267        let last = w.batch(&candles).into_iter().flatten().last().unwrap();
268        assert!(
269            last.wt1 > 0.0,
270            "uptrend wt1 should be positive, got {}",
271            last.wt1
272        );
273        assert!(
274            last.wt2 > 0.0,
275            "uptrend wt2 should be positive, got {}",
276            last.wt2
277        );
278    }
279
280    #[test]
281    fn pure_downtrend_is_negative() {
282        let candles: Vec<Candle> = (0..120_i64)
283            .map(|i| {
284                let base = 200.0 - (i as f64) * 0.5;
285                candle(base + 1.0, base - 0.5, base - 0.5, i)
286            })
287            .collect();
288        let mut w = WaveTrend::classic().unwrap();
289        let last = w.batch(&candles).into_iter().flatten().last().unwrap();
290        assert!(last.wt1 < 0.0);
291        assert!(last.wt2 < 0.0);
292    }
293
294    #[test]
295    fn outputs_remain_finite() {
296        let candles: Vec<Candle> = (0..200_i64)
297            .map(|i| {
298                let p = 100.0 + ((i as f64) * 0.3).sin() * 8.0;
299                candle(p + 2.0, p - 2.0, p, i)
300            })
301            .collect();
302        let mut w = WaveTrend::classic().unwrap();
303        for v in w.batch(&candles).into_iter().flatten() {
304            assert!(v.wt1.is_finite() && v.wt2.is_finite());
305        }
306    }
307
308    #[test]
309    fn batch_equals_streaming() {
310        let candles: Vec<Candle> = (0..120_i64)
311            .map(|i| {
312                let p = 100.0 + ((i as f64) * 0.27).sin() * 6.0;
313                candle(p + 1.5, p - 1.5, p, i)
314            })
315            .collect();
316        let mut a = WaveTrend::classic().unwrap();
317        let mut b = WaveTrend::classic().unwrap();
318        assert_eq!(
319            a.batch(&candles),
320            candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
321        );
322    }
323
324    #[test]
325    fn reset_clears_state() {
326        let candles: Vec<Candle> = (0..80_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
327        let mut w = WaveTrend::classic().unwrap();
328        w.batch(&candles);
329        assert!(w.is_ready());
330        w.reset();
331        assert!(!w.is_ready());
332        assert_eq!(w.update(candles[0]), None);
333    }
334}