Skip to main content

wickra_core/indicators/
td_setup.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Setup (9-bar buy / sell setup).
4//!
5//! The TD Setup is the first half of DeMark's TD Sequential. It counts how many
6//! consecutive bars satisfy a fixed price-comparison rule relative to the close
7//! `lookback` bars earlier (the canonical lookback is 4 — i.e. compare `close[i]`
8//! to `close[i-4]`).
9//!
10//! - A **buy setup** advances by one for each bar whose close is *less than* the
11//!   close `lookback` bars earlier. The streak resets to zero as soon as the
12//!   condition fails. A "completed" buy setup is a streak of 9 (DeMark's
13//!   default `target`).
14//! - A **sell setup** advances symmetrically when the close is *greater than*
15//!   the close `lookback` bars earlier.
16//!
17//! Only one direction can be active on a given bar: the same bar cannot satisfy
18//! both `close < close[-4]` and `close > close[-4]`. If neither condition
19//! holds (equality with the lookback close) both streaks reset.
20//!
21//! This indicator emits a signed count: positive values mean the buy-setup
22//! streak is active, negative values mean the sell-setup streak is active,
23//! and `0` means neither streak is active on the current bar. The magnitude is
24//! the current run length, capped at `target` once the setup completes — the
25//! caller can detect "perfected" setups by waiting for `value.abs() ==
26//! target`.
27
28use std::collections::VecDeque;
29
30use crate::error::{Error, Result};
31use crate::ohlcv::Candle;
32use crate::traits::Indicator;
33
34/// TD Setup state machine: counts consecutive bars meeting DeMark's setup
35/// comparison rule against the close `lookback` bars earlier.
36#[derive(Debug, Clone)]
37pub struct TdSetup {
38    lookback: usize,
39    target: usize,
40    closes: VecDeque<f64>,
41    buy_count: usize,
42    sell_count: usize,
43    last_value: Option<f64>,
44}
45
46impl TdSetup {
47    /// Construct a TD Setup with an explicit lookback and target count.
48    ///
49    /// The classic DeMark configuration is `lookback = 4` and `target = 9`.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::PeriodZero`] if either argument is zero.
54    pub fn new(lookback: usize, target: usize) -> Result<Self> {
55        if lookback == 0 || target == 0 {
56            return Err(Error::PeriodZero);
57        }
58        Ok(Self {
59            lookback,
60            target,
61            closes: VecDeque::with_capacity(lookback + 1),
62            buy_count: 0,
63            sell_count: 0,
64            last_value: None,
65        })
66    }
67
68    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
69    pub fn classic() -> Self {
70        Self::new(4, 9).expect("classic TD Setup parameters are valid")
71    }
72
73    /// Configured `(lookback, target)`.
74    pub const fn params(&self) -> (usize, usize) {
75        (self.lookback, self.target)
76    }
77
78    /// Current signed setup value if available.
79    pub const fn value(&self) -> Option<f64> {
80        self.last_value
81    }
82}
83
84impl Indicator for TdSetup {
85    type Input = Candle;
86    type Output = f64;
87
88    fn update(&mut self, candle: Candle) -> Option<f64> {
89        // Maintain a rolling window of the last `lookback + 1` closes so the
90        // oldest entry (front) is exactly the close `lookback` bars ago.
91        if self.closes.len() > self.lookback {
92            self.closes.pop_front();
93        }
94        if self.closes.len() < self.lookback {
95            self.closes.push_back(candle.close);
96            return None;
97        }
98        // We now have exactly `lookback` historical closes buffered; the oldest
99        // is the comparison reference.
100        let reference = *self.closes.front().expect("non-empty after the guard");
101        self.closes.push_back(candle.close);
102
103        if candle.close < reference {
104            self.buy_count = (self.buy_count + 1).min(self.target);
105            self.sell_count = 0;
106            let v = self.buy_count as f64;
107            self.last_value = Some(v);
108            Some(v)
109        } else if candle.close > reference {
110            self.sell_count = (self.sell_count + 1).min(self.target);
111            self.buy_count = 0;
112            let v = -(self.sell_count as f64);
113            self.last_value = Some(v);
114            Some(v)
115        } else {
116            // Equality breaks both streaks; the bar emits zero.
117            self.buy_count = 0;
118            self.sell_count = 0;
119            self.last_value = Some(0.0);
120            Some(0.0)
121        }
122    }
123
124    fn reset(&mut self) {
125        self.closes.clear();
126        self.buy_count = 0;
127        self.sell_count = 0;
128        self.last_value = None;
129    }
130
131    fn warmup_period(&self) -> usize {
132        self.lookback + 1
133    }
134
135    fn is_ready(&self) -> bool {
136        self.last_value.is_some()
137    }
138
139    fn name(&self) -> &'static str {
140        "TDSetup"
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::traits::BatchExt;
148
149    fn c(close: f64, ts: i64) -> Candle {
150        Candle::new_unchecked(close, close, close, close, 0.0, ts)
151    }
152
153    #[test]
154    fn pure_uptrend_reaches_sell_setup_9() {
155        // Every close is strictly greater than four bars ago, so the sell
156        // streak advances by one per bar from the moment lookback is filled.
157        let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
158        let mut setup = TdSetup::classic();
159        let out = setup.batch(&candles);
160        // Indices 0..4 are warmup. Index 4 is the first bar with a reference.
161        // Sell-setup advances each bar: -1 at idx 4, -2 at idx 5, …, -9 at
162        // idx 12; from there it caps at -9 because target is 9.
163        for (i, v) in out.iter().enumerate().take(4) {
164            assert!(v.is_none(), "index {i} must be None during warmup");
165        }
166        assert_eq!(out[4], Some(-1.0));
167        assert_eq!(out[5], Some(-2.0));
168        assert_eq!(out[12], Some(-9.0));
169        assert_eq!(out[13], Some(-9.0));
170        assert_eq!(out[19], Some(-9.0));
171    }
172
173    #[test]
174    fn pure_downtrend_reaches_buy_setup_9() {
175        let candles: Vec<Candle> = (1..=20)
176            .rev()
177            .enumerate()
178            .map(|(i, v)| c(f64::from(v), i64::try_from(i).unwrap()))
179            .collect();
180        let mut setup = TdSetup::classic();
181        let out = setup.batch(&candles);
182        // Buy streak should mirror the sell case: +1 at idx 4, capping at +9.
183        assert_eq!(out[4], Some(1.0));
184        assert_eq!(out[12], Some(9.0));
185        assert_eq!(out[19], Some(9.0));
186    }
187
188    #[test]
189    fn flat_series_emits_zero_after_warmup() {
190        // Every close equals the reference close (lookback bars earlier), so
191        // neither streak ever advances; the indicator emits 0 every bar.
192        let candles: Vec<Candle> = (0..20).map(|i| c(42.0, i)).collect();
193        let mut setup = TdSetup::classic();
194        let out = setup.batch(&candles);
195        for v in out.iter().skip(4) {
196            assert_eq!(*v, Some(0.0));
197        }
198    }
199
200    #[test]
201    fn streak_resets_on_direction_flip() {
202        // First 4 closes are warmup. Then 4 strictly-lower closes -> buy
203        // streak 1..=4. The next close is higher than its reference -> the
204        // buy streak resets and the sell streak starts at 1.
205        let candles = [
206            c(10.0, 0),
207            c(10.0, 1),
208            c(10.0, 2),
209            c(10.0, 3),
210            c(9.0, 4),
211            c(8.0, 5),
212            c(7.0, 6),
213            c(6.0, 7),
214            c(11.0, 8),
215        ];
216        let mut setup = TdSetup::classic();
217        let out = setup.batch(&candles);
218        assert_eq!(out[4], Some(1.0));
219        assert_eq!(out[7], Some(4.0));
220        assert_eq!(out[8], Some(-1.0));
221    }
222
223    #[test]
224    fn rejects_zero_arguments() {
225        assert!(matches!(TdSetup::new(0, 9), Err(Error::PeriodZero)));
226        assert!(matches!(TdSetup::new(4, 0), Err(Error::PeriodZero)));
227    }
228
229    #[test]
230    fn batch_equals_streaming() {
231        let candles: Vec<Candle> = (0..80)
232            .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0, i64::from(i)))
233            .collect();
234        let mut a = TdSetup::classic();
235        let mut b = TdSetup::classic();
236        assert_eq!(
237            a.batch(&candles),
238            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
239        );
240    }
241
242    #[test]
243    fn reset_clears_state() {
244        let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
245        let mut setup = TdSetup::classic();
246        setup.batch(&candles);
247        assert!(setup.is_ready());
248        setup.reset();
249        assert!(!setup.is_ready());
250        assert_eq!(setup.update(candles[0]), None);
251        assert_eq!(setup.value(), None);
252    }
253
254    #[test]
255    fn accessors_and_metadata() {
256        let setup = TdSetup::new(4, 9).unwrap();
257        assert_eq!(setup.params(), (4, 9));
258        assert_eq!(setup.warmup_period(), 5);
259        assert_eq!(setup.name(), "TDSetup");
260        assert_eq!(setup.value(), None);
261    }
262}