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        Ok(Self {
69            strength,
70            window: VecDeque::with_capacity(2 * strength + 1),
71            last_is_high: None,
72            last_extreme: 0.0,
73            wave: 0,
74            last_value: None,
75        })
76    }
77
78    /// Configured fractal strength.
79    pub const fn strength(&self) -> usize {
80        self.strength
81    }
82
83    /// Current wave number if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.last_value
86    }
87
88    fn advance(&mut self, is_high: bool, price: f64) {
89        match self.last_is_high {
90            Some(prev) if prev == is_high => {
91                // Same-direction extreme: extend the current leg if more extreme.
92                let extends = if is_high {
93                    price > self.last_extreme
94                } else {
95                    price < self.last_extreme
96                };
97                if extends {
98                    self.last_extreme = price;
99                }
100            }
101            _ => {
102                // A new alternating leg: advance the wave counter (1..8 cycle).
103                self.wave = self.wave % 8 + 1;
104                self.last_is_high = Some(is_high);
105                self.last_extreme = price;
106                self.last_value = Some(self.wave as f64);
107            }
108        }
109    }
110}
111
112impl Indicator for TdDWave {
113    type Input = Candle;
114    type Output = f64;
115
116    fn update(&mut self, candle: Candle) -> Option<f64> {
117        let span = 2 * self.strength + 1;
118        if self.window.len() == span {
119            self.window.pop_front();
120        }
121        self.window.push_back(candle);
122        if self.window.len() == span {
123            let center = self.window[self.strength];
124            let is_high = self
125                .window
126                .iter()
127                .enumerate()
128                .all(|(i, c)| i == self.strength || c.high < center.high);
129            let is_low = self
130                .window
131                .iter()
132                .enumerate()
133                .all(|(i, c)| i == self.strength || c.low > center.low);
134            if is_high && !is_low {
135                self.advance(true, center.high);
136            } else if is_low && !is_high {
137                self.advance(false, center.low);
138            }
139        }
140        self.last_value
141    }
142
143    fn reset(&mut self) {
144        self.window.clear();
145        self.last_is_high = None;
146        self.last_extreme = 0.0;
147        self.wave = 0;
148        self.last_value = None;
149    }
150
151    fn warmup_period(&self) -> usize {
152        2 * self.strength + 1
153    }
154
155    fn is_ready(&self) -> bool {
156        self.last_value.is_some()
157    }
158
159    fn name(&self) -> &'static str {
160        "TDDWave"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::traits::BatchExt;
168
169    fn c(high: f64, low: f64) -> Candle {
170        Candle::new_unchecked(
171            f64::midpoint(high, low),
172            high,
173            low,
174            f64::midpoint(high, low),
175            1_000.0,
176            0,
177        )
178    }
179
180    fn zigzag() -> Vec<Candle> {
181        (0..200)
182            .map(|i| {
183                let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
184                c(base + 1.0, base - 1.0)
185            })
186            .collect()
187    }
188
189    #[test]
190    fn rejects_zero_strength() {
191        assert!(matches!(TdDWave::new(0), Err(Error::PeriodZero)));
192    }
193
194    #[test]
195    fn accessors_and_metadata() {
196        let td = TdDWave::new(2).unwrap();
197        assert_eq!(td.strength(), 2);
198        assert_eq!(td.warmup_period(), 5);
199        assert_eq!(td.name(), "TDDWave");
200        assert!(!td.is_ready());
201        assert_eq!(td.value(), None);
202    }
203
204    #[test]
205    fn counts_waves_on_swings() {
206        let mut td = TdDWave::new(2).unwrap();
207        let out = td.batch(&zigzag());
208        assert!(out.iter().any(Option::is_some));
209        assert!(td.is_ready());
210    }
211
212    #[test]
213    fn same_direction_pivots_extend_one_leg() {
214        // Strictly decreasing lows mean no bar is ever a low pivot, so the
215        // confirmed pivots are all highs. Consecutive same-direction highs
216        // exercise the `extends` branch (true at 30 > 20, false at 25 < 30)
217        // without ever advancing the wave past leg 1.
218        let mut td = TdDWave::new(1).unwrap();
219        let bars = [
220            (10.0, 100.0),
221            (20.0, 99.0),
222            (12.0, 98.0),
223            (30.0, 97.0),
224            (15.0, 96.0),
225            (25.0, 95.0),
226            (14.0, 94.0),
227            (14.0, 93.0),
228        ];
229        let vals: Vec<f64> = bars
230            .iter()
231            .filter_map(|&(high, low)| td.update(c(high, low)))
232            .collect();
233        assert!(!vals.is_empty());
234        assert!(vals.iter().all(|&v| v == 1.0));
235    }
236
237    #[test]
238    fn same_direction_low_pivots_extend_one_leg() {
239        // Mirror of the high-pivot case: strictly increasing highs mean no bar
240        // is ever a high pivot, so the confirmed pivots are all lows. The
241        // `extends` else-branch fires (true at 2 < 5, false at 4 > 2).
242        let mut td = TdDWave::new(1).unwrap();
243        let bars = [
244            (100.0, 10.0),
245            (101.0, 5.0),
246            (102.0, 8.0),
247            (103.0, 2.0),
248            (104.0, 6.0),
249            (105.0, 4.0),
250            (106.0, 7.0),
251            (107.0, 7.0),
252        ];
253        let vals: Vec<f64> = bars
254            .iter()
255            .filter_map(|&(high, low)| td.update(c(high, low)))
256            .collect();
257        assert!(!vals.is_empty());
258        assert!(vals.iter().all(|&v| v == 1.0));
259    }
260
261    #[test]
262    fn wave_stays_in_one_to_eight() {
263        let mut td = TdDWave::new(2).unwrap();
264        for v in td.batch(&zigzag()).into_iter().flatten() {
265            assert!((1.0..=8.0).contains(&v), "wave out of range: {v}");
266        }
267    }
268
269    #[test]
270    fn flat_input_never_counts() {
271        // A perfectly flat series has no distinct swing highs/lows.
272        let mut td = TdDWave::new(2).unwrap();
273        let candles: Vec<Candle> = (0..40).map(|_| c(100.0, 100.0)).collect();
274        assert!(td.batch(&candles).iter().all(Option::is_none));
275    }
276
277    #[test]
278    fn reset_clears_state() {
279        let mut td = TdDWave::new(2).unwrap();
280        td.batch(&zigzag());
281        assert!(td.is_ready());
282        td.reset();
283        assert!(!td.is_ready());
284        assert_eq!(td.value(), None);
285    }
286
287    #[test]
288    fn batch_equals_streaming() {
289        let candles = zigzag();
290        let batch = TdDWave::new(2).unwrap().batch(&candles);
291        let mut b = TdDWave::new(2).unwrap();
292        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
293        assert_eq!(batch, streamed);
294    }
295}