Skip to main content

wickra_core/indicators/
td_sequential.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Sequential (Setup + Countdown).
4//!
5//! TD Sequential is DeMark's flagship two-phase exhaustion pattern:
6//!
7//! 1. **Setup phase** — 9 consecutive bars whose close is less-than (buy
8//!    setup) or greater-than (sell setup) the close 4 bars earlier. The
9//!    setup *completes* on the 9th bar.
10//! 2. **Countdown phase** — after a completed setup, count up to 13 bars
11//!    that satisfy the countdown comparison (buy countdown: `close <= low`
12//!    two bars earlier; sell countdown: `close >= high` two bars earlier).
13//!    Countdown bars do not need to be consecutive.
14//!
15//! A completed countdown (13) signals exhaustion in the direction of the
16//! original setup and is the canonical DeMark reversal signal.
17//!
18//! Output struct `TdSequentialOutput`:
19//!
20//! - `setup`: signed setup count (positive for buy setup, negative for sell
21//!   setup, 0 when no streak is active; capped at ±9).
22//! - `countdown`: signed countdown count (positive for buy countdown, negative
23//!   for sell countdown, 0 when no countdown is active; capped at ±13).
24//! - `direction`: `+1.0` if a buy countdown is currently active, `-1.0` if a
25//!   sell countdown is active, `0.0` otherwise. The countdown direction is
26//!   set when the originating setup completes and stays valid until the
27//!   countdown finishes or is invalidated by an opposite-direction setup.
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 Sequential countdown phase.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Direction {
38    None,
39    Buy,
40    Sell,
41}
42
43/// Output of [`TdSequential`]: setup count, countdown count, and active
44/// countdown direction.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct TdSequentialOutput {
47    /// Signed setup count: +N for an active buy setup of length `N`, −N for
48    /// a sell setup of length `N`, 0 if neither streak is active. Capped at
49    /// ±9 (the canonical setup target).
50    pub setup: f64,
51    /// Signed countdown count: +N for an active buy countdown of length `N`,
52    /// −N for a sell countdown of length `N`, 0 if no countdown is active.
53    /// Capped at ±13.
54    pub countdown: f64,
55    /// Direction of the active countdown: `+1.0` for buy, `−1.0` for sell,
56    /// `0.0` if no countdown is currently active.
57    pub direction: f64,
58}
59
60/// TD Sequential state machine: combined Setup (1-9) + Countdown (1-13).
61#[derive(Debug, Clone)]
62pub struct TdSequential {
63    // Rolling window of recent candles. We need up to 5 closes back (for the
64    // setup rule which compares close[i] vs close[i-4]) and the high/low from
65    // 2 bars ago (for the countdown rule).
66    candles: VecDeque<Candle>,
67    setup_lookback: usize,
68    setup_target: usize,
69    countdown_lookback: usize,
70    countdown_target: usize,
71    buy_setup: usize,
72    sell_setup: usize,
73    buy_countdown: usize,
74    sell_countdown: usize,
75    countdown_dir: Direction,
76    ready: bool,
77}
78
79impl TdSequential {
80    /// Construct a TD Sequential with explicit lookbacks and targets. The
81    /// canonical DeMark configuration is `setup_lookback = 4`, `setup_target =
82    /// 9`, `countdown_lookback = 2`, `countdown_target = 13`.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`Error::PeriodZero`] if any argument is zero.
87    pub fn new(
88        setup_lookback: usize,
89        setup_target: usize,
90        countdown_lookback: usize,
91        countdown_target: usize,
92    ) -> Result<Self> {
93        if setup_lookback == 0
94            || setup_target == 0
95            || countdown_lookback == 0
96            || countdown_target == 0
97        {
98            return Err(Error::PeriodZero);
99        }
100        // Need to keep enough candles for both rules: setup uses close[-N];
101        // countdown uses high/low[-M]. Reserve `max(N, M) + 1` slots.
102        let cap = setup_lookback.max(countdown_lookback) + 1;
103        Ok(Self {
104            candles: VecDeque::with_capacity(cap),
105            setup_lookback,
106            setup_target,
107            countdown_lookback,
108            countdown_target,
109            buy_setup: 0,
110            sell_setup: 0,
111            buy_countdown: 0,
112            sell_countdown: 0,
113            countdown_dir: Direction::None,
114            ready: false,
115        })
116    }
117
118    /// DeMark's classic configuration: setup `lookback = 4, target = 9`,
119    /// countdown `lookback = 2, target = 13`.
120    pub fn classic() -> Self {
121        Self::new(4, 9, 2, 13).expect("classic TD Sequential parameters are valid")
122    }
123
124    /// Configured `(setup_lookback, setup_target, countdown_lookback,
125    /// countdown_target)`.
126    pub const fn params(&self) -> (usize, usize, usize, usize) {
127        (
128            self.setup_lookback,
129            self.setup_target,
130            self.countdown_lookback,
131            self.countdown_target,
132        )
133    }
134}
135
136impl Indicator for TdSequential {
137    type Input = Candle;
138    type Output = TdSequentialOutput;
139
140    fn update(&mut self, candle: Candle) -> Option<TdSequentialOutput> {
141        let cap = self.setup_lookback.max(self.countdown_lookback) + 1;
142        if self.candles.len() == cap {
143            self.candles.pop_front();
144        }
145        // The required minimum history is `max(setup_lookback,
146        // countdown_lookback)` previous bars. Once we have that many, we can
147        // evaluate both rules.
148        let need = self.setup_lookback.max(self.countdown_lookback);
149        if self.candles.len() < need {
150            self.candles.push_back(candle);
151            return None;
152        }
153
154        // --- Setup rule: compare to close[setup_lookback bars ago] ---
155        // After `need` candles are buffered, the candle at offset `need - L`
156        // from the front is the one `L` bars before the new candle (0-based
157        // count: `front()` is `need` bars ago).
158        let setup_ref_idx = need - self.setup_lookback;
159        let setup_ref_close = self.candles[setup_ref_idx].close;
160
161        if candle.close < setup_ref_close {
162            self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
163            self.sell_setup = 0;
164        } else if candle.close > setup_ref_close {
165            self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
166            self.buy_setup = 0;
167        } else {
168            self.buy_setup = 0;
169            self.sell_setup = 0;
170        }
171
172        // --- Countdown activation: when a setup completes, arm the countdown
173        // in the same direction; an opposite-direction setup invalidates any
174        // active countdown.
175        if self.buy_setup == self.setup_target {
176            if self.countdown_dir != Direction::Buy {
177                self.buy_countdown = 0;
178                self.sell_countdown = 0;
179            }
180            self.countdown_dir = Direction::Buy;
181        } else if self.sell_setup == self.setup_target {
182            if self.countdown_dir != Direction::Sell {
183                self.buy_countdown = 0;
184                self.sell_countdown = 0;
185            }
186            self.countdown_dir = Direction::Sell;
187        }
188
189        // --- Countdown rule: compare close to high/low `countdown_lookback`
190        // bars ago. Only the active direction advances. Once a countdown
191        // reaches `countdown_target`, the strict `< countdown_target` guard
192        // keeps it pinned so the caller can detect the "13" signal on this
193        // bar and any subsequent bar until a new setup arms a fresh run.
194        let cd_ref_idx = need - self.countdown_lookback;
195        let cd_ref = &self.candles[cd_ref_idx];
196        match self.countdown_dir {
197            Direction::Buy => {
198                if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target {
199                    self.buy_countdown += 1;
200                }
201            }
202            Direction::Sell => {
203                if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target {
204                    self.sell_countdown += 1;
205                }
206            }
207            Direction::None => {}
208        }
209
210        self.candles.push_back(candle);
211        self.ready = true;
212
213        let setup = if self.buy_setup > 0 {
214            self.buy_setup as f64
215        } else if self.sell_setup > 0 {
216            -(self.sell_setup as f64)
217        } else {
218            0.0
219        };
220        let (countdown, direction) = match self.countdown_dir {
221            Direction::Buy => (self.buy_countdown as f64, 1.0),
222            Direction::Sell => (-(self.sell_countdown as f64), -1.0),
223            Direction::None => (0.0, 0.0),
224        };
225
226        Some(TdSequentialOutput {
227            setup,
228            countdown,
229            direction,
230        })
231    }
232
233    fn reset(&mut self) {
234        self.candles.clear();
235        self.buy_setup = 0;
236        self.sell_setup = 0;
237        self.buy_countdown = 0;
238        self.sell_countdown = 0;
239        self.countdown_dir = Direction::None;
240        self.ready = false;
241    }
242
243    fn warmup_period(&self) -> usize {
244        self.setup_lookback.max(self.countdown_lookback) + 1
245    }
246
247    fn is_ready(&self) -> bool {
248        self.ready
249    }
250
251    fn name(&self) -> &'static str {
252        "TDSequential"
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::traits::BatchExt;
260
261    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
262        Candle::new_unchecked(close, high, low, close, 0.0, ts)
263    }
264
265    #[test]
266    fn pure_uptrend_completes_sell_setup_then_progresses_countdown() {
267        // Strictly increasing closes -> sell setup increments every bar past
268        // warmup, reaching -9 by index 12 (warmup is 4 + 1). After that,
269        // every bar continues to make a higher close, so each subsequent bar
270        // also makes a higher close than the high 2 bars ago — the sell
271        // countdown increments on each bar after activation.
272        let candles: Vec<Candle> = (1..=40)
273            .map(|i| {
274                c(
275                    f64::from(i) + 0.5,
276                    f64::from(i) - 0.5,
277                    f64::from(i),
278                    i64::from(i),
279                )
280            })
281            .collect();
282        let mut td = TdSequential::classic();
283        let out = td.batch(&candles);
284
285        // Warmup: indices 0..3 yield None (need=4 prior closes).
286        for v in out.iter().take(4) {
287            assert!(v.is_none());
288        }
289        // After index 12, setup reaches -9 (completed). From the next bar on,
290        // countdown begins to increment.
291        let at_12 = out[12].expect("setup ready");
292        assert_eq!(at_12.setup, -9.0);
293        assert_eq!(at_12.direction, -1.0); // countdown direction armed
294
295        // Each subsequent bar makes close > high[i-2], so the sell countdown
296        // advances by one per bar; by some later index it caps at -13.
297        let later = out[30].expect("ready");
298        assert_eq!(later.direction, -1.0);
299        assert_eq!(later.countdown, -13.0);
300    }
301
302    #[test]
303    fn pure_downtrend_completes_buy_setup_then_progresses_countdown() {
304        // Strictly decreasing closes -> buy setup increments every bar past
305        // warmup, reaching 9 by index 12. After activation, every subsequent
306        // bar satisfies close <= low[i-2], so the buy countdown advances by
307        // one per bar and pins at +13.
308        let candles: Vec<Candle> = (1..=40)
309            .rev()
310            .enumerate()
311            .map(|(k, i)| {
312                c(
313                    f64::from(i) + 0.5,
314                    f64::from(i) - 0.5,
315                    f64::from(i),
316                    i64::try_from(k).unwrap(),
317                )
318            })
319            .collect();
320        let mut td = TdSequential::classic();
321        let out = td.batch(&candles);
322
323        // Warmup: indices 0..3 yield None.
324        for v in out.iter().take(4) {
325            assert!(v.is_none());
326        }
327        let at_12 = out[12].expect("setup ready");
328        assert_eq!(at_12.setup, 9.0);
329        assert_eq!(at_12.direction, 1.0); // buy direction armed
330
331        // By idx 30 the buy countdown has saturated at +13.
332        let later = out[30].expect("ready");
333        assert_eq!(later.direction, 1.0);
334        assert_eq!(later.countdown, 13.0);
335    }
336
337    #[test]
338    fn flat_series_emits_zero_setup_and_no_countdown() {
339        // All closes equal -> never completes any setup; countdown never
340        // activates; setup, countdown, direction all stay at 0.
341        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
342        let mut td = TdSequential::classic();
343        let out = td.batch(&candles);
344        for v in out.iter().skip(5) {
345            let o = v.expect("ready post-warmup");
346            assert_eq!(o.setup, 0.0);
347            assert_eq!(o.countdown, 0.0);
348            assert_eq!(o.direction, 0.0);
349        }
350    }
351
352    #[test]
353    fn batch_equals_streaming() {
354        let candles: Vec<Candle> = (0..60)
355            .map(|i| {
356                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
357                c(m + 1.0, m - 1.0, m, i64::from(i))
358            })
359            .collect();
360        let mut a = TdSequential::classic();
361        let mut b = TdSequential::classic();
362        assert_eq!(
363            a.batch(&candles),
364            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
365        );
366    }
367
368    #[test]
369    fn rejects_invalid_params() {
370        assert!(matches!(
371            TdSequential::new(0, 9, 2, 13),
372            Err(Error::PeriodZero)
373        ));
374        assert!(matches!(
375            TdSequential::new(4, 0, 2, 13),
376            Err(Error::PeriodZero)
377        ));
378        assert!(matches!(
379            TdSequential::new(4, 9, 0, 13),
380            Err(Error::PeriodZero)
381        ));
382        assert!(matches!(
383            TdSequential::new(4, 9, 2, 0),
384            Err(Error::PeriodZero)
385        ));
386    }
387
388    #[test]
389    fn reset_clears_state() {
390        let candles: Vec<Candle> = (1..=20)
391            .map(|i| {
392                c(
393                    f64::from(i) + 0.5,
394                    f64::from(i) - 0.5,
395                    f64::from(i),
396                    i64::from(i),
397                )
398            })
399            .collect();
400        let mut td = TdSequential::classic();
401        td.batch(&candles);
402        assert!(td.is_ready());
403        td.reset();
404        assert!(!td.is_ready());
405        assert_eq!(td.update(candles[0]), None);
406    }
407
408    #[test]
409    fn accessors_and_metadata() {
410        let td = TdSequential::classic();
411        assert_eq!(td.params(), (4, 9, 2, 13));
412        assert_eq!(td.warmup_period(), 5);
413        assert_eq!(td.name(), "TDSequential");
414    }
415}