Skip to main content

wickra_core/indicators/
td_combo.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Combo — an aggressive variant of TD Countdown.
4//!
5//! TD Combo is DeMark's stricter countdown variant. Unlike vanilla TD
6//! Sequential (which only requires `close <= low[i - 2]` for a buy
7//! countdown), Combo adds two strictness conditions that prevent the
8//! countdown from advancing on weak bars:
9//!
10//! - **Buy combo** bars must satisfy:
11//!   1. `close[i] <= low[i - 2]`               (the classic countdown rule)
12//!   2. `low[i]   <= low[i - 1]`               (monotone strictly-non-rising lows)
13//!   3. `close[i] <  close[i - 1]`             (each combo bar must close strictly lower)
14//! - **Sell combo** bars must satisfy the mirror set:
15//!   1. `close[i] >= high[i - 2]`
16//!   2. `high[i]  >= high[i - 1]`
17//!   3. `close[i] >  close[i - 1]`
18//!
19//! Like vanilla countdown, the combo is *armed* by a completed 9-bar setup
20//! (same definition as [`crate::TdSetup`]) in the same direction. The combo
21//! count saturates at `target` (DeMark's classic value is `13`).
22//!
23//! Output is a signed counter: positive for an active buy-combo run,
24//! negative for a sell-combo run, `0.0` when no combo is currently armed.
25
26use std::collections::VecDeque;
27
28use crate::error::{Error, Result};
29use crate::ohlcv::Candle;
30use crate::traits::Indicator;
31
32/// Direction of an active TD Combo run.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum Direction {
35    None,
36    Buy,
37    Sell,
38}
39
40/// TD Combo — aggressive countdown variant.
41/// # Example
42///
43/// ```
44/// use wickra_core::{TdCombo, Candle, Indicator};
45///
46/// let mut indicator = TdCombo::new(4, 9, 2, 13).unwrap();
47/// // `None` during warmup, then `Some(_)` once enough bars are seen.
48/// let mut out = None;
49/// for i in 0..40i64 {
50///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
51///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
52///     out = indicator.update(candle);
53/// }
54/// let _ = out;
55/// ```
56#[derive(Debug, Clone)]
57pub struct TdCombo {
58    setup_lookback: usize,
59    setup_target: usize,
60    countdown_lookback: usize,
61    countdown_target: usize,
62    candles: VecDeque<Candle>,
63    buy_setup: usize,
64    sell_setup: usize,
65    buy_combo: usize,
66    sell_combo: usize,
67    direction: Direction,
68    ready: bool,
69}
70
71impl TdCombo {
72    /// Construct a TD Combo with explicit lookbacks and targets. The
73    /// canonical DeMark configuration is `setup_lookback = 4`,
74    /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`Error::PeriodZero`] if any argument is zero.
79    pub fn new(
80        setup_lookback: usize,
81        setup_target: usize,
82        countdown_lookback: usize,
83        countdown_target: usize,
84    ) -> Result<Self> {
85        if setup_lookback == 0
86            || setup_target == 0
87            || countdown_lookback == 0
88            || countdown_target == 0
89        {
90            return Err(Error::PeriodZero);
91        }
92        let cap = setup_lookback.max(countdown_lookback) + 1;
93        Ok(Self {
94            setup_lookback,
95            setup_target,
96            countdown_lookback,
97            countdown_target,
98            candles: VecDeque::with_capacity(cap),
99            buy_setup: 0,
100            sell_setup: 0,
101            buy_combo: 0,
102            sell_combo: 0,
103            direction: Direction::None,
104            ready: false,
105        })
106    }
107
108    /// DeMark's classic configuration: setup `lookback = 4, target = 9`,
109    /// combo `lookback = 2, target = 13`.
110    pub fn classic() -> Self {
111        Self::new(4, 9, 2, 13).expect("classic TD Combo parameters are valid")
112    }
113
114    /// Configured `(setup_lookback, setup_target, countdown_lookback,
115    /// countdown_target)`.
116    pub const fn params(&self) -> (usize, usize, usize, usize) {
117        (
118            self.setup_lookback,
119            self.setup_target,
120            self.countdown_lookback,
121            self.countdown_target,
122        )
123    }
124}
125
126impl Indicator for TdCombo {
127    type Input = Candle;
128    type Output = f64;
129
130    fn update(&mut self, candle: Candle) -> Option<f64> {
131        let need = self.setup_lookback.max(self.countdown_lookback);
132        let cap = need + 1;
133        if self.candles.len() == cap {
134            self.candles.pop_front();
135        }
136        if self.candles.len() < need {
137            self.candles.push_back(candle);
138            return None;
139        }
140
141        // Setup rule: compare to close[setup_lookback bars ago].
142        let setup_ref_idx = need - self.setup_lookback;
143        let setup_ref_close = self.candles[setup_ref_idx].close;
144        if candle.close < setup_ref_close {
145            self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
146            self.sell_setup = 0;
147        } else if candle.close > setup_ref_close {
148            self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
149            self.buy_setup = 0;
150        } else {
151            self.buy_setup = 0;
152            self.sell_setup = 0;
153        }
154
155        // Combo arming: a completed setup in either direction arms the
156        // combo in the same direction (resetting any opposite-direction
157        // combo count first).
158        if self.buy_setup == self.setup_target {
159            if self.direction != Direction::Buy {
160                self.buy_combo = 0;
161                self.sell_combo = 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_combo = 0;
167                self.sell_combo = 0;
168            }
169            self.direction = Direction::Sell;
170        }
171
172        // Combo rule references the candle `countdown_lookback` bars ago
173        // (high / low) and the immediately-prior candle (low / high /
174        // close monotone strictness).
175        let combo_ref = self.candles[need - self.countdown_lookback];
176        let prev = self.candles[need - 1];
177        match self.direction {
178            Direction::Buy => {
179                let cond_classic = candle.close <= combo_ref.low;
180                let cond_low = candle.low <= prev.low;
181                let cond_close = candle.close < prev.close;
182                if cond_classic && cond_low && cond_close && self.buy_combo < self.countdown_target
183                {
184                    self.buy_combo += 1;
185                }
186            }
187            Direction::Sell => {
188                let cond_classic = candle.close >= combo_ref.high;
189                let cond_high = candle.high >= prev.high;
190                let cond_close = candle.close > prev.close;
191                if cond_classic
192                    && cond_high
193                    && cond_close
194                    && self.sell_combo < self.countdown_target
195                {
196                    self.sell_combo += 1;
197                }
198            }
199            Direction::None => {}
200        }
201
202        self.candles.push_back(candle);
203        self.ready = true;
204
205        let v = match self.direction {
206            Direction::Buy => self.buy_combo as f64,
207            Direction::Sell => -(self.sell_combo as f64),
208            Direction::None => 0.0,
209        };
210        Some(v)
211    }
212
213    fn reset(&mut self) {
214        self.candles.clear();
215        self.buy_setup = 0;
216        self.sell_setup = 0;
217        self.buy_combo = 0;
218        self.sell_combo = 0;
219        self.direction = Direction::None;
220        self.ready = false;
221    }
222
223    fn warmup_period(&self) -> usize {
224        self.setup_lookback.max(self.countdown_lookback) + 1
225    }
226
227    fn is_ready(&self) -> bool {
228        self.ready
229    }
230
231    fn name(&self) -> &'static str {
232        "TDCombo"
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::traits::BatchExt;
240
241    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
242        Candle::new_unchecked(close, high, low, close, 0.0, ts)
243    }
244
245    #[test]
246    fn pure_uptrend_arms_sell_combo_and_advances() {
247        // Strictly increasing closes -> sell setup completes at idx 12,
248        // then every subsequent bar satisfies the three sell-combo
249        // strictness conditions, so combo advances by one per bar and
250        // saturates at -13.
251        let candles: Vec<Candle> = (1..=40)
252            .map(|i| {
253                c(
254                    f64::from(i) + 0.5,
255                    f64::from(i) - 0.5,
256                    f64::from(i),
257                    i64::from(i),
258                )
259            })
260            .collect();
261        let mut combo = TdCombo::classic();
262        let out = combo.batch(&candles);
263        // First emit is at index 4 (warmup is 5).
264        for v in out.iter().take(4) {
265            assert!(v.is_none());
266        }
267        // At idx 12 the setup completes and combo direction is sell; on
268        // the same bar the combo rule fires once because the
269        // monotone-strictness conditions hold for a strictly-rising
270        // series, so combo == -1.
271        let at_12 = out[12].expect("ready");
272        assert_eq!(at_12, -1.0);
273        // By idx 30 the combo has saturated at -13.
274        let later = out[30].expect("ready");
275        assert_eq!(later, -13.0);
276    }
277
278    #[test]
279    fn pure_downtrend_arms_buy_combo_and_advances() {
280        // Strictly decreasing closes -> buy setup completes at idx 12,
281        // then every subsequent bar satisfies the three buy-combo
282        // strictness conditions, so combo advances by one per bar and
283        // saturates at +13.
284        let candles: Vec<Candle> = (1..=40)
285            .rev()
286            .enumerate()
287            .map(|(k, i)| {
288                c(
289                    f64::from(i) + 0.5,
290                    f64::from(i) - 0.5,
291                    f64::from(i),
292                    i64::try_from(k).unwrap(),
293                )
294            })
295            .collect();
296        let mut combo = TdCombo::classic();
297        let out = combo.batch(&candles);
298        for v in out.iter().take(4) {
299            assert!(v.is_none());
300        }
301        // At idx 12 the setup completes and combo direction is buy; on
302        // the same bar the combo rule fires once because the
303        // monotone-strictness conditions hold for a strictly-falling
304        // series, so combo == +1.
305        let at_12 = out[12].expect("ready");
306        assert_eq!(at_12, 1.0);
307        // By idx 30 the combo has saturated at +13.
308        let later = out[30].expect("ready");
309        assert_eq!(later, 13.0);
310    }
311
312    #[test]
313    fn flat_series_never_arms_combo() {
314        // All closes equal -> setup never completes -> combo never arms.
315        let candles: Vec<Candle> = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
316        let mut combo = TdCombo::classic();
317        for v in combo.batch(&candles).into_iter().flatten() {
318            assert_eq!(v, 0.0);
319        }
320    }
321
322    #[test]
323    fn batch_equals_streaming() {
324        let candles: Vec<Candle> = (0..80)
325            .map(|i| {
326                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
327                c(m + 1.0, m - 1.0, m, i64::from(i))
328            })
329            .collect();
330        let mut a = TdCombo::classic();
331        let mut b = TdCombo::classic();
332        assert_eq!(
333            a.batch(&candles),
334            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
335        );
336    }
337
338    #[test]
339    fn rejects_invalid_params() {
340        assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero)));
341        assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero)));
342        assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero)));
343        assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero)));
344    }
345
346    #[test]
347    fn reset_clears_state() {
348        let candles: Vec<Candle> = (1..=30)
349            .map(|i| {
350                c(
351                    f64::from(i) + 0.5,
352                    f64::from(i) - 0.5,
353                    f64::from(i),
354                    i64::from(i),
355                )
356            })
357            .collect();
358        let mut combo = TdCombo::classic();
359        combo.batch(&candles);
360        assert!(combo.is_ready());
361        combo.reset();
362        assert!(!combo.is_ready());
363        assert_eq!(combo.update(candles[0]), None);
364    }
365
366    #[test]
367    fn accessors_and_metadata() {
368        let combo = TdCombo::classic();
369        assert_eq!(combo.params(), (4, 9, 2, 13));
370        assert_eq!(combo.warmup_period(), 5);
371        assert_eq!(combo.name(), "TDCombo");
372    }
373}