Skip to main content

wickra_core/indicators/
td_countdown.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Countdown (standalone 13-bar countdown).
4//!
5//! The Countdown is the second half of DeMark's TD Sequential, packaged
6//! here as a standalone indicator that runs the setup-detection phase
7//! internally and then exposes only the countdown count (and direction)
8//! to callers who don't need the running setup state.
9//!
10//! - **Setup detection** (internal): 9 consecutive bars whose close is
11//!   less-than (buy setup) or greater-than (sell setup) the close
12//!   `setup_lookback` bars earlier.
13//! - **Buy countdown** advances on bars where `close[i] <= low[i -
14//!   countdown_lookback]` (need not be consecutive). Saturates at
15//!   `countdown_target` (13 in DeMark's classic configuration).
16//! - **Sell countdown** advances on bars where `close[i] >= high[i -
17//!   countdown_lookback]`.
18//! - **Bar-13 qualifier:** the final countdown bar must also trade through the
19//!   close of countdown bar `countdown_target − 5` (bar 8 of 13): its low at or
20//!   below that close for a buy, its high at or above it for a sell. A bar that
21//!   meets the comparison but not the qualifier is deferred, not counted.
22//! - An opposite-direction setup completion invalidates the active
23//!   countdown (count resets to zero in the new direction).
24//!
25//! Output is a signed counter: positive for an active buy countdown,
26//! negative for an active sell countdown, and `0.0` when no countdown is
27//! currently armed.
28//!
29//! This indicator differs from [`crate::TdSequential`] only in its
30//! output shape: callers who only need the countdown value (and not the
31//! running setup count) can use this for a smaller streaming payload.
32
33use std::collections::VecDeque;
34
35use crate::error::{Error, Result};
36use crate::ohlcv::Candle;
37use crate::traits::Indicator;
38
39/// Direction of an active TD Countdown phase.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Direction {
42    None,
43    Buy,
44    Sell,
45}
46
47/// TD Countdown — standalone 13-bar countdown.
48/// # Example
49///
50/// ```
51/// use wickra_core::{TdCountdown, Candle, Indicator};
52///
53/// let mut indicator = TdCountdown::new(4, 9, 2, 13).unwrap();
54/// // `None` during warmup, then `Some(_)` once enough bars are seen.
55/// let mut out = None;
56/// for i in 0..40i64 {
57///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
58///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
59///     out = indicator.update(candle);
60/// }
61/// let _ = out;
62/// ```
63#[derive(Debug, Clone)]
64pub struct TdCountdown {
65    setup_lookback: usize,
66    setup_target: usize,
67    countdown_lookback: usize,
68    countdown_target: usize,
69    candles: VecDeque<Candle>,
70    buy_setup: usize,
71    sell_setup: usize,
72    buy_countdown: usize,
73    sell_countdown: usize,
74    /// Close of countdown bar `countdown_target − 5` (bar 8 of 13), which bar
75    /// 13 must reach; `NaN` until that bar is counted.
76    qualifier_close: f64,
77    direction: Direction,
78    ready: bool,
79}
80
81impl TdCountdown {
82    /// Construct a TD Countdown with explicit lookbacks and targets. The
83    /// canonical DeMark configuration is `setup_lookback = 4`,
84    /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`Error::PeriodZero`] if any argument is zero.
89    pub fn new(
90        setup_lookback: usize,
91        setup_target: usize,
92        countdown_lookback: usize,
93        countdown_target: usize,
94    ) -> Result<Self> {
95        if setup_lookback == 0
96            || setup_target == 0
97            || countdown_lookback == 0
98            || countdown_target == 0
99        {
100            return Err(Error::PeriodZero);
101        }
102        let cap = setup_lookback.max(countdown_lookback) + 1;
103        Ok(Self {
104            setup_lookback,
105            setup_target,
106            countdown_lookback,
107            countdown_target,
108            candles: VecDeque::with_capacity(cap),
109            buy_setup: 0,
110            sell_setup: 0,
111            buy_countdown: 0,
112            sell_countdown: 0,
113            qualifier_close: f64::NAN,
114            direction: Direction::None,
115            ready: false,
116        })
117    }
118
119    /// DeMark's classic configuration: setup `lookback = 4, target = 9`,
120    /// countdown `lookback = 2, target = 13`.
121    pub fn classic() -> Self {
122        Self::new(4, 9, 2, 13).expect("classic TD Countdown parameters are valid")
123    }
124
125    /// Configured `(setup_lookback, setup_target, countdown_lookback,
126    /// countdown_target)`.
127    pub const fn params(&self) -> (usize, usize, usize, usize) {
128        (
129            self.setup_lookback,
130            self.setup_target,
131            self.countdown_lookback,
132            self.countdown_target,
133        )
134    }
135}
136
137impl Indicator for TdCountdown {
138    type Input = Candle;
139    type Output = f64;
140
141    fn update(&mut self, candle: Candle) -> Option<f64> {
142        let need = self.setup_lookback.max(self.countdown_lookback);
143        let cap = need + 1;
144        if self.candles.len() == cap {
145            self.candles.pop_front();
146        }
147        if self.candles.len() < need {
148            self.candles.push_back(candle);
149            return None;
150        }
151
152        // Setup rule: compare to close[setup_lookback bars ago].
153        let setup_ref_idx = need - self.setup_lookback;
154        let setup_ref_close = self.candles[setup_ref_idx].close;
155        if candle.close < setup_ref_close {
156            self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
157            self.sell_setup = 0;
158        } else if candle.close > setup_ref_close {
159            self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
160            self.buy_setup = 0;
161        } else {
162            self.buy_setup = 0;
163            self.sell_setup = 0;
164        }
165
166        if self.buy_setup == self.setup_target {
167            if self.direction != Direction::Buy {
168                self.buy_countdown = 0;
169                self.sell_countdown = 0;
170                self.qualifier_close = f64::NAN;
171            }
172            self.direction = Direction::Buy;
173        } else if self.sell_setup == self.setup_target {
174            if self.direction != Direction::Sell {
175                self.buy_countdown = 0;
176                self.sell_countdown = 0;
177                self.qualifier_close = f64::NAN;
178            }
179            self.direction = Direction::Sell;
180        }
181
182        let cd_ref = self.candles[need - self.countdown_lookback];
183        match self.direction {
184            Direction::Buy => {
185                if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target {
186                    // The final bar must also trade at or below the close of
187                    // countdown bar 8; otherwise it is deferred.
188                    let next = self.buy_countdown + 1;
189                    if next < self.countdown_target
190                        || (self.qualifier_close.is_nan() || candle.low <= self.qualifier_close)
191                    {
192                        self.buy_countdown = next;
193                        if next + 5 == self.countdown_target {
194                            self.qualifier_close = candle.close;
195                        }
196                    }
197                }
198            }
199            Direction::Sell => {
200                if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target {
201                    // The final bar must also trade at or above the close of
202                    // countdown bar 8; otherwise it is deferred.
203                    let next = self.sell_countdown + 1;
204                    if next < self.countdown_target
205                        || (self.qualifier_close.is_nan() || candle.high >= self.qualifier_close)
206                    {
207                        self.sell_countdown = next;
208                        if next + 5 == self.countdown_target {
209                            self.qualifier_close = candle.close;
210                        }
211                    }
212                }
213            }
214            Direction::None => {}
215        }
216
217        self.candles.push_back(candle);
218        self.ready = true;
219
220        let v = match self.direction {
221            Direction::Buy => self.buy_countdown as f64,
222            Direction::Sell => -(self.sell_countdown as f64),
223            Direction::None => 0.0,
224        };
225        Some(v)
226    }
227
228    fn reset(&mut self) {
229        self.candles.clear();
230        self.buy_setup = 0;
231        self.sell_setup = 0;
232        self.buy_countdown = 0;
233        self.sell_countdown = 0;
234        self.qualifier_close = f64::NAN;
235        self.direction = Direction::None;
236        self.ready = false;
237    }
238
239    #[inline]
240    fn warmup_period(&self) -> usize {
241        self.setup_lookback.max(self.countdown_lookback) + 1
242    }
243
244    #[inline]
245    fn is_ready(&self) -> bool {
246        self.ready
247    }
248
249    #[inline]
250    fn name(&self) -> &'static str {
251        "TDCountdown"
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::traits::BatchExt;
259
260    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
261        Candle::new_unchecked(close, high, low, close, 0.0, ts)
262    }
263
264    #[test]
265    fn pure_uptrend_completes_setup_then_runs_sell_countdown_to_minus_13() {
266        let candles: Vec<Candle> = (1..=40)
267            .map(|i| {
268                c(
269                    f64::from(i) + 0.5,
270                    f64::from(i) - 0.5,
271                    f64::from(i),
272                    i64::from(i),
273                )
274            })
275            .collect();
276        let mut td = TdCountdown::classic();
277        let out = td.batch(&candles);
278        // Warmup: 4 None values.
279        for v in out.iter().take(4) {
280            assert!(v.is_none());
281        }
282        // At idx 12 the sell setup completes; on the same bar the
283        // countdown rule fires once because close > high[i-2] for a
284        // strictly-rising series, so countdown == -1.
285        assert_eq!(out[12].expect("ready"), -1.0);
286        // After enough bars the countdown saturates at -13.
287        assert_eq!(out[30].expect("ready"), -13.0);
288    }
289
290    #[test]
291    fn pure_downtrend_completes_setup_then_runs_buy_countdown_to_plus_13() {
292        let candles: Vec<Candle> = (1..=40)
293            .rev()
294            .enumerate()
295            .map(|(k, i)| {
296                c(
297                    f64::from(i) + 0.5,
298                    f64::from(i) - 0.5,
299                    f64::from(i),
300                    i64::try_from(k).unwrap(),
301                )
302            })
303            .collect();
304        let mut td = TdCountdown::classic();
305        let out = td.batch(&candles);
306        for v in out.iter().take(4) {
307            assert!(v.is_none());
308        }
309        // At idx 12 the buy setup completes; on the same bar the
310        // countdown rule fires once because close < low[i-2] for a
311        // strictly-falling series, so countdown == +1.
312        assert_eq!(out[12].expect("ready"), 1.0);
313        // After enough bars the countdown saturates at +13.
314        assert_eq!(out[30].expect("ready"), 13.0);
315    }
316
317    #[test]
318    fn flat_series_never_arms_countdown() {
319        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
320        let mut td = TdCountdown::classic();
321        for v in td.batch(&candles).into_iter().flatten() {
322            assert_eq!(v, 0.0);
323        }
324    }
325
326    #[test]
327    fn batch_equals_streaming() {
328        let candles: Vec<Candle> = (0..80)
329            .map(|i| {
330                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
331                c(m + 1.0, m - 1.0, m, i64::from(i))
332            })
333            .collect();
334        let mut a = TdCountdown::classic();
335        let mut b = TdCountdown::classic();
336        assert_eq!(
337            a.batch(&candles),
338            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
339        );
340    }
341
342    #[test]
343    fn rejects_invalid_params() {
344        assert!(matches!(
345            TdCountdown::new(0, 9, 2, 13),
346            Err(Error::PeriodZero)
347        ));
348        assert!(matches!(
349            TdCountdown::new(4, 0, 2, 13),
350            Err(Error::PeriodZero)
351        ));
352        assert!(matches!(
353            TdCountdown::new(4, 9, 0, 13),
354            Err(Error::PeriodZero)
355        ));
356        assert!(matches!(
357            TdCountdown::new(4, 9, 2, 0),
358            Err(Error::PeriodZero)
359        ));
360    }
361
362    #[test]
363    fn reset_clears_state() {
364        let candles: Vec<Candle> = (1..=30)
365            .map(|i| {
366                c(
367                    f64::from(i) + 0.5,
368                    f64::from(i) - 0.5,
369                    f64::from(i),
370                    i64::from(i),
371                )
372            })
373            .collect();
374        let mut td = TdCountdown::classic();
375        td.batch(&candles);
376        assert!(td.is_ready());
377        td.reset();
378        assert!(!td.is_ready());
379        assert_eq!(td.update(candles[0]), None);
380    }
381
382    #[test]
383    fn accessors_and_metadata() {
384        let td = TdCountdown::classic();
385        assert_eq!(td.params(), (4, 9, 2, 13));
386        assert_eq!(td.warmup_period(), 5);
387        assert_eq!(td.name(), "TDCountdown");
388    }
389
390    /// Candles with a +-0.5 range around each close, timestamped by index.
391    fn from_closes(closes: &[f64]) -> Vec<Candle> {
392        closes
393            .iter()
394            .enumerate()
395            .map(|(k, &m)| c(m + 0.5, m - 0.5, m, i64::try_from(k).unwrap()))
396            .collect()
397    }
398
399    /// Buy-side deferral series. Closes fall 100 -> 77 (idx 0..=23): the buy
400    /// setup completes at idx 12 (countdown 1), countdown bar 8 is idx 19
401    /// (close 81, the stored qualifier) and idx 23 reaches countdown 12.
402    /// A rally (90, 95, 95) follows, then idx 27 closes at 89 <= low[25] =
403    /// 94.5 (countdown comparison met) but its low 88.5 > 81, so bar 13 is
404    /// deferred. Idx 28 closes at 80 <= low[26] = 94.5 with low 79.5 <= 81,
405    /// which completes the countdown. The rally only builds a sell setup of 4.
406    fn buy_deferral_closes() -> Vec<f64> {
407        let mut closes: Vec<f64> = (77..=100).rev().map(f64::from).collect();
408        closes.extend([90.0, 95.0, 95.0, 89.0, 80.0]);
409        closes
410    }
411
412    /// Mirror image of [`buy_deferral_closes`] around 100: the sell qualifier
413    /// is close 119 at idx 19; idx 27 (high 111.5 < 119) is deferred and idx
414    /// 28 (high 120.5 >= 119) completes the sell countdown.
415    fn sell_deferral_closes() -> Vec<f64> {
416        buy_deferral_closes().iter().map(|x| 200.0 - x).collect()
417    }
418
419    #[test]
420    fn buy_bar_13_is_deferred_until_low_reaches_bar_8_close() {
421        let mut td = TdCountdown::classic();
422        let out = td.batch(&from_closes(&buy_deferral_closes()));
423        assert_eq!(out[19], Some(8.0));
424        assert_eq!(out[23], Some(12.0));
425        // Rally bars and the deferred bar all keep the count at 12.
426        assert!(out[24..28].iter().all(|v| *v == Some(12.0)));
427        assert_eq!(out[28], Some(13.0));
428    }
429
430    #[test]
431    fn sell_bar_13_is_deferred_until_high_reaches_bar_8_close() {
432        let mut td = TdCountdown::classic();
433        let out = td.batch(&from_closes(&sell_deferral_closes()));
434        assert_eq!(out[19], Some(-8.0));
435        assert_eq!(out[23], Some(-12.0));
436        assert!(out[24..28].iter().all(|v| *v == Some(-12.0)));
437        assert_eq!(out[28], Some(-13.0));
438    }
439
440    #[test]
441    fn qualifier_close_is_bar_8_close() {
442        let mut td = TdCountdown::classic();
443        let candles = from_closes(&buy_deferral_closes());
444        for candle in &candles[..19] {
445            td.update(*candle);
446        }
447        assert!(td.qualifier_close.is_nan());
448        td.update(candles[19]);
449        assert_eq!(td.qualifier_close.to_bits(), 81.0_f64.to_bits());
450    }
451
452    #[test]
453    fn short_target_has_no_qualifier() {
454        // countdown_target = 3 <= 5: no bar 8 exists, so the qualifier stays
455        // NaN and the final bar completes unconditionally (idx 12, 13, 14).
456        let closes: Vec<f64> = (70..=100).rev().map(f64::from).collect();
457        let candles = from_closes(&closes);
458        let mut buy = TdCountdown::new(4, 9, 2, 3).unwrap();
459        let out = buy.batch(&candles);
460        assert_eq!(out[12], Some(1.0));
461        assert_eq!(out[14], Some(3.0));
462        assert_eq!(out[30], Some(3.0));
463        assert!(buy.qualifier_close.is_nan());
464
465        let rising: Vec<f64> = closes.iter().map(|x| 200.0 - x).collect();
466        let mut sell = TdCountdown::new(4, 9, 2, 3).unwrap();
467        let out = sell.batch(&from_closes(&rising));
468        assert_eq!(out[14], Some(-3.0));
469        assert_eq!(out[30], Some(-3.0));
470        assert!(sell.qualifier_close.is_nan());
471    }
472
473    #[test]
474    fn opposite_setup_invalidates_and_clears_qualifier() {
475        // Buy countdown reaches 12 at idx 23 with the qualifier stored (81);
476        // then closes rise 78, 79, ... Idx 24 (78 < 80) and idx 25 (79 == 79)
477        // do not count, so the sell setup runs idx 26..=34 and completes at
478        // idx 34 (close 88), resetting everything.
479        let mut closes: Vec<f64> = (77..=100).rev().map(f64::from).collect();
480        closes.extend((78..=120).map(f64::from));
481        let candles = from_closes(&closes);
482        let mut td = TdCountdown::classic();
483        let out: Vec<Option<f64>> = candles.iter().map(|x| td.update(*x)).collect();
484        assert_eq!(out[23], Some(12.0));
485        assert_eq!(out[33], Some(12.0));
486        // idx 34: invalidated; close 88 >= high[32] = 86.5 so the sell
487        // countdown starts at 1 on the same bar and reaches 13 at idx 46.
488        assert_eq!(out[34], Some(-1.0));
489        assert_eq!(out[46], Some(-13.0));
490
491        let mut probe = TdCountdown::classic();
492        for candle in &candles[..34] {
493            probe.update(*candle);
494        }
495        assert_eq!(probe.qualifier_close.to_bits(), 81.0_f64.to_bits());
496        probe.update(candles[34]);
497        assert!(probe.qualifier_close.is_nan());
498
499        // And back again: a buy setup after the sell countdown re-arms buy.
500        let mut back = closes.clone();
501        back.extend((60..=119).rev().map(f64::from));
502        let mut td2 = TdCountdown::classic();
503        let out2 = td2.batch(&from_closes(&back));
504        let last = out2.last().copied().flatten().unwrap();
505        assert_eq!(last.to_bits(), 13.0_f64.to_bits());
506    }
507
508    #[test]
509    fn first_value_lands_at_warmup_minus_one() {
510        let candles = from_closes(&buy_deferral_closes());
511        for (sl, cl) in [(4, 2), (2, 6), (1, 1)] {
512            let mut td = TdCountdown::new(sl, 9, cl, 13).unwrap();
513            let warm = td.warmup_period();
514            let out = td.batch(&candles);
515            assert!(out[..warm - 1].iter().all(Option::is_none));
516            assert!(out[warm - 1].is_some());
517        }
518    }
519
520    #[test]
521    fn reset_reproduces_fresh_run() {
522        let candles = from_closes(&sell_deferral_closes());
523        let mut fresh = TdCountdown::classic();
524        let expected = fresh.batch(&candles);
525        let mut td = TdCountdown::classic();
526        td.batch(&from_closes(&buy_deferral_closes()));
527        td.reset();
528        assert!(td.qualifier_close.is_nan());
529        assert_eq!(td.batch(&candles), expected);
530    }
531
532    #[test]
533    fn batch_nan_into_matches_streaming() {
534        let candles = from_closes(&buy_deferral_closes());
535        let mut a = TdCountdown::classic();
536        let mut out = vec![0.0; candles.len()];
537        a.batch_nan_into(&candles, &mut out);
538        let mut b = TdCountdown::classic();
539        let streamed: Vec<f64> = candles
540            .iter()
541            .map(|x| b.update(*x).unwrap_or(f64::NAN))
542            .collect();
543        assert!(out
544            .iter()
545            .zip(&streamed)
546            .all(|(x, y)| x.to_bits() == y.to_bits()));
547    }
548}