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#[derive(Debug, Clone)]
42pub struct TdCombo {
43    setup_lookback: usize,
44    setup_target: usize,
45    countdown_lookback: usize,
46    countdown_target: usize,
47    candles: VecDeque<Candle>,
48    buy_setup: usize,
49    sell_setup: usize,
50    buy_combo: usize,
51    sell_combo: usize,
52    direction: Direction,
53    ready: bool,
54}
55
56impl TdCombo {
57    /// Construct a TD Combo with explicit lookbacks and targets. The
58    /// canonical DeMark configuration is `setup_lookback = 4`,
59    /// `setup_target = 9`, `countdown_lookback = 2`, `countdown_target = 13`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if any argument is zero.
64    pub fn new(
65        setup_lookback: usize,
66        setup_target: usize,
67        countdown_lookback: usize,
68        countdown_target: usize,
69    ) -> Result<Self> {
70        if setup_lookback == 0
71            || setup_target == 0
72            || countdown_lookback == 0
73            || countdown_target == 0
74        {
75            return Err(Error::PeriodZero);
76        }
77        let cap = setup_lookback.max(countdown_lookback) + 1;
78        Ok(Self {
79            setup_lookback,
80            setup_target,
81            countdown_lookback,
82            countdown_target,
83            candles: VecDeque::with_capacity(cap),
84            buy_setup: 0,
85            sell_setup: 0,
86            buy_combo: 0,
87            sell_combo: 0,
88            direction: Direction::None,
89            ready: false,
90        })
91    }
92
93    /// DeMark's classic configuration: setup `lookback = 4, target = 9`,
94    /// combo `lookback = 2, target = 13`.
95    pub fn classic() -> Self {
96        Self::new(4, 9, 2, 13).expect("classic TD Combo parameters are valid")
97    }
98
99    /// Configured `(setup_lookback, setup_target, countdown_lookback,
100    /// countdown_target)`.
101    pub const fn params(&self) -> (usize, usize, usize, usize) {
102        (
103            self.setup_lookback,
104            self.setup_target,
105            self.countdown_lookback,
106            self.countdown_target,
107        )
108    }
109}
110
111impl Indicator for TdCombo {
112    type Input = Candle;
113    type Output = f64;
114
115    fn update(&mut self, candle: Candle) -> Option<f64> {
116        let need = self.setup_lookback.max(self.countdown_lookback);
117        let cap = need + 1;
118        if self.candles.len() == cap {
119            self.candles.pop_front();
120        }
121        if self.candles.len() < need {
122            self.candles.push_back(candle);
123            return None;
124        }
125
126        // Setup rule: compare to close[setup_lookback bars ago].
127        let setup_ref_idx = need - self.setup_lookback;
128        let setup_ref_close = self.candles[setup_ref_idx].close;
129        if candle.close < setup_ref_close {
130            self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
131            self.sell_setup = 0;
132        } else if candle.close > setup_ref_close {
133            self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
134            self.buy_setup = 0;
135        } else {
136            self.buy_setup = 0;
137            self.sell_setup = 0;
138        }
139
140        // Combo arming: a completed setup in either direction arms the
141        // combo in the same direction (resetting any opposite-direction
142        // combo count first).
143        if self.buy_setup == self.setup_target {
144            if self.direction != Direction::Buy {
145                self.buy_combo = 0;
146                self.sell_combo = 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_combo = 0;
152                self.sell_combo = 0;
153            }
154            self.direction = Direction::Sell;
155        }
156
157        // Combo rule references the candle `countdown_lookback` bars ago
158        // (high / low) and the immediately-prior candle (low / high /
159        // close monotone strictness).
160        let combo_ref = self.candles[need - self.countdown_lookback];
161        let prev = self.candles[need - 1];
162        match self.direction {
163            Direction::Buy => {
164                let cond_classic = candle.close <= combo_ref.low;
165                let cond_low = candle.low <= prev.low;
166                let cond_close = candle.close < prev.close;
167                if cond_classic && cond_low && cond_close && self.buy_combo < self.countdown_target
168                {
169                    self.buy_combo += 1;
170                }
171            }
172            Direction::Sell => {
173                let cond_classic = candle.close >= combo_ref.high;
174                let cond_high = candle.high >= prev.high;
175                let cond_close = candle.close > prev.close;
176                if cond_classic
177                    && cond_high
178                    && cond_close
179                    && self.sell_combo < self.countdown_target
180                {
181                    self.sell_combo += 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_combo as f64,
192            Direction::Sell => -(self.sell_combo 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_combo = 0;
203        self.sell_combo = 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        "TDCombo"
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_arms_sell_combo_and_advances() {
232        // Strictly increasing closes -> sell setup completes at idx 12,
233        // then every subsequent bar satisfies the three sell-combo
234        // strictness conditions, so combo advances by one per bar and
235        // saturates at -13.
236        let candles: Vec<Candle> = (1..=40)
237            .map(|i| {
238                c(
239                    f64::from(i) + 0.5,
240                    f64::from(i) - 0.5,
241                    f64::from(i),
242                    i64::from(i),
243                )
244            })
245            .collect();
246        let mut combo = TdCombo::classic();
247        let out = combo.batch(&candles);
248        // First emit is at index 4 (warmup is 5).
249        for v in out.iter().take(4) {
250            assert!(v.is_none());
251        }
252        // At idx 12 the setup completes and combo direction is sell; on
253        // the same bar the combo rule fires once because the
254        // monotone-strictness conditions hold for a strictly-rising
255        // series, so combo == -1.
256        let at_12 = out[12].expect("ready");
257        assert_eq!(at_12, -1.0);
258        // By idx 30 the combo has saturated at -13.
259        let later = out[30].expect("ready");
260        assert_eq!(later, -13.0);
261    }
262
263    #[test]
264    fn pure_downtrend_arms_buy_combo_and_advances() {
265        // Strictly decreasing closes -> buy setup completes at idx 12,
266        // then every subsequent bar satisfies the three buy-combo
267        // strictness conditions, so combo advances by one per bar and
268        // saturates at +13.
269        let candles: Vec<Candle> = (1..=40)
270            .rev()
271            .enumerate()
272            .map(|(k, i)| {
273                c(
274                    f64::from(i) + 0.5,
275                    f64::from(i) - 0.5,
276                    f64::from(i),
277                    i64::try_from(k).unwrap(),
278                )
279            })
280            .collect();
281        let mut combo = TdCombo::classic();
282        let out = combo.batch(&candles);
283        for v in out.iter().take(4) {
284            assert!(v.is_none());
285        }
286        // At idx 12 the setup completes and combo direction is buy; on
287        // the same bar the combo rule fires once because the
288        // monotone-strictness conditions hold for a strictly-falling
289        // series, so combo == +1.
290        let at_12 = out[12].expect("ready");
291        assert_eq!(at_12, 1.0);
292        // By idx 30 the combo has saturated at +13.
293        let later = out[30].expect("ready");
294        assert_eq!(later, 13.0);
295    }
296
297    #[test]
298    fn flat_series_never_arms_combo() {
299        // All closes equal -> setup never completes -> combo never arms.
300        let candles: Vec<Candle> = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
301        let mut combo = TdCombo::classic();
302        for v in combo.batch(&candles).into_iter().flatten() {
303            assert_eq!(v, 0.0);
304        }
305    }
306
307    #[test]
308    fn batch_equals_streaming() {
309        let candles: Vec<Candle> = (0..80)
310            .map(|i| {
311                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
312                c(m + 1.0, m - 1.0, m, i64::from(i))
313            })
314            .collect();
315        let mut a = TdCombo::classic();
316        let mut b = TdCombo::classic();
317        assert_eq!(
318            a.batch(&candles),
319            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
320        );
321    }
322
323    #[test]
324    fn rejects_invalid_params() {
325        assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero)));
326        assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero)));
327        assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero)));
328        assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero)));
329    }
330
331    #[test]
332    fn reset_clears_state() {
333        let candles: Vec<Candle> = (1..=30)
334            .map(|i| {
335                c(
336                    f64::from(i) + 0.5,
337                    f64::from(i) - 0.5,
338                    f64::from(i),
339                    i64::from(i),
340                )
341            })
342            .collect();
343        let mut combo = TdCombo::classic();
344        combo.batch(&candles);
345        assert!(combo.is_ready());
346        combo.reset();
347        assert!(!combo.is_ready());
348        assert_eq!(combo.update(candles[0]), None);
349    }
350
351    #[test]
352    fn accessors_and_metadata() {
353        let combo = TdCombo::classic();
354        assert_eq!(combo.params(), (4, 9, 2, 13));
355        assert_eq!(combo.warmup_period(), 5);
356        assert_eq!(combo.name(), "TDCombo");
357    }
358}