Skip to main content

wickra_core/indicators/
td_dwave.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD D-Wave — a simplified Elliott-style swing-wave counter.
4
5use std::collections::VecDeque;
6
7use crate::error::{Error, Result};
8use crate::ohlcv::Candle;
9use crate::traits::Indicator;
10
11/// Tom DeMark **TD D-Wave** — a streaming wave counter that labels the market's
12/// swing sequence with an Elliott-style `1–5` impulse / `A–C` correction count.
13///
14/// TD D-Wave is DeMark's objective alternative to discretionary Elliott Wave
15/// counting. This streaming implementation detects alternating swing pivots with a
16/// symmetric fractal of half-width `strength`, and advances a counter through the
17/// eight-leg cycle each time a new swing leg is confirmed:
18///
19/// ```text
20/// legs:  1 → 2 → 3 → 4 → 5 → A(6) → B(7) → C(8) → 1 …
21/// output = current wave number, 1.0..8.0   (6/7/8 = corrective A/B/C)
22/// ```
23///
24/// The number tells you which wave of the cycle price is currently working on — a
25/// running map of impulse versus correction that updates as each swing confirms.
26/// This is a **simplified** swing-leg count (it does not enforce Elliott's price
27/// ratio and overlap rules); treat it as a structural guide, not a strict wave
28/// label.
29///
30/// Readiness is data-dependent: the first value appears once the first swing pivot
31/// confirms (`strength` bars after it forms). `warmup_period` returns the minimum
32/// bars to confirm one pivot. Each `update` is O(`strength`).
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Candle, Indicator, TdDWave};
38///
39/// let mut indicator = TdDWave::new(2).unwrap();
40/// let mut last = None;
41/// for i in 0..120 {
42///     let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
43///     let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
44///     last = indicator.update(c);
45/// }
46/// let _ = last;
47/// ```
48#[derive(Debug, Clone)]
49pub struct TdDWave {
50    strength: usize,
51    window: VecDeque<Candle>,
52    last_is_high: Option<bool>,
53    last_extreme: f64,
54    wave: usize,
55    last_value: Option<f64>,
56}
57
58impl TdDWave {
59    /// Construct a TD D-Wave with the given fractal `strength`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if `strength == 0`.
64    pub fn new(strength: usize) -> Result<Self> {
65        if strength == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if strength > crate::error::MAX_PERIOD {
69            return Err(Error::InvalidPeriod {
70                message: crate::error::PERIOD_ABOVE_MAX,
71            });
72        }
73        Ok(Self {
74            strength,
75            window: VecDeque::with_capacity(2 * strength + 1),
76            last_is_high: None,
77            last_extreme: 0.0,
78            wave: 0,
79            last_value: None,
80        })
81    }
82
83    /// Configured fractal strength.
84    pub const fn strength(&self) -> usize {
85        self.strength
86    }
87
88    /// Current wave number if available.
89    pub const fn value(&self) -> Option<f64> {
90        self.last_value
91    }
92
93    fn advance(&mut self, is_high: bool, price: f64) {
94        match self.last_is_high {
95            Some(prev) if prev == is_high => {
96                // Same-direction extreme: extend the current leg if more extreme.
97                let extends = if is_high {
98                    price > self.last_extreme
99                } else {
100                    price < self.last_extreme
101                };
102                if extends {
103                    self.last_extreme = price;
104                }
105            }
106            _ => {
107                // A new alternating leg: advance the wave counter (1..8 cycle).
108                self.wave = self.wave % 8 + 1;
109                self.last_is_high = Some(is_high);
110                self.last_extreme = price;
111                self.last_value = Some(self.wave as f64);
112            }
113        }
114    }
115}
116
117impl Indicator for TdDWave {
118    type Input = Candle;
119    type Output = f64;
120
121    #[inline]
122    fn update(&mut self, candle: Candle) -> Option<f64> {
123        let span = 2 * self.strength + 1;
124        if self.window.len() == span {
125            self.window.pop_front();
126        }
127        self.window.push_back(candle);
128        if self.window.len() == span {
129            let center = self.window[self.strength];
130            let is_high = self
131                .window
132                .iter()
133                .enumerate()
134                .all(|(i, c)| i == self.strength || c.high < center.high);
135            let is_low = self
136                .window
137                .iter()
138                .enumerate()
139                .all(|(i, c)| i == self.strength || c.low > center.low);
140            if is_high && !is_low {
141                self.advance(true, center.high);
142            } else if is_low && !is_high {
143                self.advance(false, center.low);
144            }
145        }
146        self.last_value
147    }
148
149    fn reset(&mut self) {
150        self.window.clear();
151        self.last_is_high = None;
152        self.last_extreme = 0.0;
153        self.wave = 0;
154        self.last_value = None;
155    }
156
157    #[inline]
158    fn warmup_period(&self) -> usize {
159        2 * self.strength + 1
160    }
161
162    #[inline]
163    fn is_ready(&self) -> bool {
164        self.last_value.is_some()
165    }
166
167    #[inline]
168    fn name(&self) -> &'static str {
169        "TDDWave"
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::traits::BatchExt;
177
178    fn c(high: f64, low: f64) -> Candle {
179        Candle::new_unchecked(
180            f64::midpoint(high, low),
181            high,
182            low,
183            f64::midpoint(high, low),
184            1_000.0,
185            0,
186        )
187    }
188
189    fn zigzag() -> Vec<Candle> {
190        (0..200)
191            .map(|i| {
192                let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
193                c(base + 1.0, base - 1.0)
194            })
195            .collect()
196    }
197
198    #[test]
199    fn rejects_zero_strength() {
200        assert!(matches!(TdDWave::new(0), Err(Error::PeriodZero)));
201    }
202
203    #[test]
204    fn accessors_and_metadata() {
205        let td = TdDWave::new(2).unwrap();
206        assert_eq!(td.strength(), 2);
207        assert_eq!(td.warmup_period(), 5);
208        assert_eq!(td.name(), "TDDWave");
209        assert!(!td.is_ready());
210        assert_eq!(td.value(), None);
211    }
212
213    #[test]
214    fn counts_waves_on_swings() {
215        let mut td = TdDWave::new(2).unwrap();
216        let out = td.batch(&zigzag());
217        assert!(out.iter().any(Option::is_some));
218        assert!(td.is_ready());
219    }
220
221    #[test]
222    fn same_direction_pivots_extend_one_leg() {
223        // Strictly decreasing lows mean no bar is ever a low pivot, so the
224        // confirmed pivots are all highs. Consecutive same-direction highs
225        // exercise the `extends` branch (true at 30 > 20, false at 25 < 30)
226        // without ever advancing the wave past leg 1.
227        let mut td = TdDWave::new(1).unwrap();
228        let bars = [
229            (10.0, 100.0),
230            (20.0, 99.0),
231            (12.0, 98.0),
232            (30.0, 97.0),
233            (15.0, 96.0),
234            (25.0, 95.0),
235            (14.0, 94.0),
236            (14.0, 93.0),
237        ];
238        let vals: Vec<f64> = bars
239            .iter()
240            .filter_map(|&(high, low)| td.update(c(high, low)))
241            .collect();
242        assert!(!vals.is_empty());
243        assert!(vals.iter().all(|&v| v == 1.0));
244    }
245
246    #[test]
247    fn same_direction_low_pivots_extend_one_leg() {
248        // Mirror of the high-pivot case: strictly increasing highs mean no bar
249        // is ever a high pivot, so the confirmed pivots are all lows. The
250        // `extends` else-branch fires (true at 2 < 5, false at 4 > 2).
251        let mut td = TdDWave::new(1).unwrap();
252        let bars = [
253            (100.0, 10.0),
254            (101.0, 5.0),
255            (102.0, 8.0),
256            (103.0, 2.0),
257            (104.0, 6.0),
258            (105.0, 4.0),
259            (106.0, 7.0),
260            (107.0, 7.0),
261        ];
262        let vals: Vec<f64> = bars
263            .iter()
264            .filter_map(|&(high, low)| td.update(c(high, low)))
265            .collect();
266        assert!(!vals.is_empty());
267        assert!(vals.iter().all(|&v| v == 1.0));
268    }
269
270    #[test]
271    fn wave_stays_in_one_to_eight() {
272        let mut td = TdDWave::new(2).unwrap();
273        for v in td.batch(&zigzag()).into_iter().flatten() {
274            assert!((1.0..=8.0).contains(&v), "wave out of range: {v}");
275        }
276    }
277
278    #[test]
279    fn flat_input_never_counts() {
280        // A perfectly flat series has no distinct swing highs/lows.
281        let mut td = TdDWave::new(2).unwrap();
282        let candles: Vec<Candle> = (0..40).map(|_| c(100.0, 100.0)).collect();
283        assert!(td.batch(&candles).iter().all(Option::is_none));
284    }
285
286    #[test]
287    fn reset_clears_state() {
288        let mut td = TdDWave::new(2).unwrap();
289        td.batch(&zigzag());
290        assert!(td.is_ready());
291        td.reset();
292        assert!(!td.is_ready());
293        assert_eq!(td.value(), None);
294    }
295
296    #[test]
297    fn batch_equals_streaming() {
298        let candles = zigzag();
299        let batch = TdDWave::new(2).unwrap().batch(&candles);
300        let mut b = TdDWave::new(2).unwrap();
301        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
302        assert_eq!(batch, streamed);
303    }
304}