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//! - An opposite-direction setup completion invalidates the active
19//!   countdown (count resets to zero in the new direction).
20//!
21//! Output is a signed counter: positive for an active buy countdown,
22//! negative for an active sell countdown, and `0.0` when no countdown is
23//! currently armed.
24//!
25//! This indicator differs from [`crate::TdSequential`] only in its
26//! output shape: callers who only need the countdown value (and not the
27//! running setup count) can use this for a smaller streaming payload.
28
29use std::collections::VecDeque;
30
31use crate::error::{Error, Result};
32use crate::ohlcv::Candle;
33use crate::traits::Indicator;
34
35/// Direction of an active TD Countdown phase.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Direction {
38    None,
39    Buy,
40    Sell,
41}
42
43/// TD Countdown — standalone 13-bar countdown.
44#[derive(Debug, Clone)]
45pub struct TdCountdown {
46    setup_lookback: usize,
47    setup_target: usize,
48    countdown_lookback: usize,
49    countdown_target: usize,
50    candles: VecDeque<Candle>,
51    buy_setup: usize,
52    sell_setup: usize,
53    buy_countdown: usize,
54    sell_countdown: usize,
55    direction: Direction,
56    ready: bool,
57}
58
59impl TdCountdown {
60    /// Construct a TD Countdown with explicit lookbacks and targets. The
61    /// canonical DeMark configuration is `setup_lookback = 4`,
62    /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::PeriodZero`] if any argument is zero.
67    pub fn new(
68        setup_lookback: usize,
69        setup_target: usize,
70        countdown_lookback: usize,
71        countdown_target: usize,
72    ) -> Result<Self> {
73        if setup_lookback == 0
74            || setup_target == 0
75            || countdown_lookback == 0
76            || countdown_target == 0
77        {
78            return Err(Error::PeriodZero);
79        }
80        let cap = setup_lookback.max(countdown_lookback) + 1;
81        Ok(Self {
82            setup_lookback,
83            setup_target,
84            countdown_lookback,
85            countdown_target,
86            candles: VecDeque::with_capacity(cap),
87            buy_setup: 0,
88            sell_setup: 0,
89            buy_countdown: 0,
90            sell_countdown: 0,
91            direction: Direction::None,
92            ready: false,
93        })
94    }
95
96    /// DeMark's classic configuration: setup `lookback = 4, target = 9`,
97    /// countdown `lookback = 2, target = 13`.
98    pub fn classic() -> Self {
99        Self::new(4, 9, 2, 13).expect("classic TD Countdown parameters are valid")
100    }
101
102    /// Configured `(setup_lookback, setup_target, countdown_lookback,
103    /// countdown_target)`.
104    pub const fn params(&self) -> (usize, usize, usize, usize) {
105        (
106            self.setup_lookback,
107            self.setup_target,
108            self.countdown_lookback,
109            self.countdown_target,
110        )
111    }
112}
113
114impl Indicator for TdCountdown {
115    type Input = Candle;
116    type Output = f64;
117
118    fn update(&mut self, candle: Candle) -> Option<f64> {
119        let need = self.setup_lookback.max(self.countdown_lookback);
120        let cap = need + 1;
121        if self.candles.len() == cap {
122            self.candles.pop_front();
123        }
124        if self.candles.len() < need {
125            self.candles.push_back(candle);
126            return None;
127        }
128
129        // Setup rule: compare to close[setup_lookback bars ago].
130        let setup_ref_idx = need - self.setup_lookback;
131        let setup_ref_close = self.candles[setup_ref_idx].close;
132        if candle.close < setup_ref_close {
133            self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
134            self.sell_setup = 0;
135        } else if candle.close > setup_ref_close {
136            self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
137            self.buy_setup = 0;
138        } else {
139            self.buy_setup = 0;
140            self.sell_setup = 0;
141        }
142
143        if self.buy_setup == self.setup_target {
144            if self.direction != Direction::Buy {
145                self.buy_countdown = 0;
146                self.sell_countdown = 0;
147            }
148            self.direction = Direction::Buy;
149        } else if self.sell_setup == self.setup_target {
150            if self.direction != Direction::Sell {
151                self.buy_countdown = 0;
152                self.sell_countdown = 0;
153            }
154            self.direction = Direction::Sell;
155        }
156
157        let cd_ref = self.candles[need - self.countdown_lookback];
158        match self.direction {
159            Direction::Buy => {
160                if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target {
161                    self.buy_countdown += 1;
162                }
163            }
164            Direction::Sell => {
165                if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target {
166                    self.sell_countdown += 1;
167                }
168            }
169            Direction::None => {}
170        }
171
172        self.candles.push_back(candle);
173        self.ready = true;
174
175        let v = match self.direction {
176            Direction::Buy => self.buy_countdown as f64,
177            Direction::Sell => -(self.sell_countdown as f64),
178            Direction::None => 0.0,
179        };
180        Some(v)
181    }
182
183    fn reset(&mut self) {
184        self.candles.clear();
185        self.buy_setup = 0;
186        self.sell_setup = 0;
187        self.buy_countdown = 0;
188        self.sell_countdown = 0;
189        self.direction = Direction::None;
190        self.ready = false;
191    }
192
193    fn warmup_period(&self) -> usize {
194        self.setup_lookback.max(self.countdown_lookback) + 1
195    }
196
197    fn is_ready(&self) -> bool {
198        self.ready
199    }
200
201    fn name(&self) -> &'static str {
202        "TDCountdown"
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::traits::BatchExt;
210
211    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
212        Candle::new_unchecked(close, high, low, close, 0.0, ts)
213    }
214
215    #[test]
216    fn pure_uptrend_completes_setup_then_runs_sell_countdown_to_minus_13() {
217        let candles: Vec<Candle> = (1..=40)
218            .map(|i| {
219                c(
220                    f64::from(i) + 0.5,
221                    f64::from(i) - 0.5,
222                    f64::from(i),
223                    i64::from(i),
224                )
225            })
226            .collect();
227        let mut td = TdCountdown::classic();
228        let out = td.batch(&candles);
229        // Warmup: 4 None values.
230        for v in out.iter().take(4) {
231            assert!(v.is_none());
232        }
233        // At idx 12 the sell setup completes; on the same bar the
234        // countdown rule fires once because close > high[i-2] for a
235        // strictly-rising series, so countdown == -1.
236        assert_eq!(out[12].expect("ready"), -1.0);
237        // After enough bars the countdown saturates at -13.
238        assert_eq!(out[30].expect("ready"), -13.0);
239    }
240
241    #[test]
242    fn pure_downtrend_completes_setup_then_runs_buy_countdown_to_plus_13() {
243        let candles: Vec<Candle> = (1..=40)
244            .rev()
245            .enumerate()
246            .map(|(k, i)| {
247                c(
248                    f64::from(i) + 0.5,
249                    f64::from(i) - 0.5,
250                    f64::from(i),
251                    i64::try_from(k).unwrap(),
252                )
253            })
254            .collect();
255        let mut td = TdCountdown::classic();
256        let out = td.batch(&candles);
257        for v in out.iter().take(4) {
258            assert!(v.is_none());
259        }
260        // At idx 12 the buy setup completes; on the same bar the
261        // countdown rule fires once because close < low[i-2] for a
262        // strictly-falling series, so countdown == +1.
263        assert_eq!(out[12].expect("ready"), 1.0);
264        // After enough bars the countdown saturates at +13.
265        assert_eq!(out[30].expect("ready"), 13.0);
266    }
267
268    #[test]
269    fn flat_series_never_arms_countdown() {
270        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
271        let mut td = TdCountdown::classic();
272        for v in td.batch(&candles).into_iter().flatten() {
273            assert_eq!(v, 0.0);
274        }
275    }
276
277    #[test]
278    fn batch_equals_streaming() {
279        let candles: Vec<Candle> = (0..80)
280            .map(|i| {
281                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
282                c(m + 1.0, m - 1.0, m, i64::from(i))
283            })
284            .collect();
285        let mut a = TdCountdown::classic();
286        let mut b = TdCountdown::classic();
287        assert_eq!(
288            a.batch(&candles),
289            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
290        );
291    }
292
293    #[test]
294    fn rejects_invalid_params() {
295        assert!(matches!(
296            TdCountdown::new(0, 9, 2, 13),
297            Err(Error::PeriodZero)
298        ));
299        assert!(matches!(
300            TdCountdown::new(4, 0, 2, 13),
301            Err(Error::PeriodZero)
302        ));
303        assert!(matches!(
304            TdCountdown::new(4, 9, 0, 13),
305            Err(Error::PeriodZero)
306        ));
307        assert!(matches!(
308            TdCountdown::new(4, 9, 2, 0),
309            Err(Error::PeriodZero)
310        ));
311    }
312
313    #[test]
314    fn reset_clears_state() {
315        let candles: Vec<Candle> = (1..=30)
316            .map(|i| {
317                c(
318                    f64::from(i) + 0.5,
319                    f64::from(i) - 0.5,
320                    f64::from(i),
321                    i64::from(i),
322                )
323            })
324            .collect();
325        let mut td = TdCountdown::classic();
326        td.batch(&candles);
327        assert!(td.is_ready());
328        td.reset();
329        assert!(!td.is_ready());
330        assert_eq!(td.update(candles[0]), None);
331    }
332
333    #[test]
334    fn accessors_and_metadata() {
335        let td = TdCountdown::classic();
336        assert_eq!(td.params(), (4, 9, 2, 13));
337        assert_eq!(td.warmup_period(), 5);
338        assert_eq!(td.name(), "TDCountdown");
339    }
340}