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