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