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    fn update(&mut self, candle: Candle) -> Option<f64> {
104        // Maintain a rolling window of the last `lookback + 1` closes so the
105        // oldest entry (front) is exactly the close `lookback` bars ago.
106        if self.closes.len() > self.lookback {
107            self.closes.pop_front();
108        }
109        if self.closes.len() < self.lookback {
110            self.closes.push_back(candle.close);
111            return None;
112        }
113        // We now have exactly `lookback` historical closes buffered; the oldest
114        // is the comparison reference.
115        let reference = *self.closes.front().expect("non-empty after the guard");
116        self.closes.push_back(candle.close);
117
118        if candle.close < reference {
119            self.buy_count = (self.buy_count + 1).min(self.target);
120            self.sell_count = 0;
121            let v = self.buy_count as f64;
122            self.last_value = Some(v);
123            Some(v)
124        } else if candle.close > reference {
125            self.sell_count = (self.sell_count + 1).min(self.target);
126            self.buy_count = 0;
127            let v = -(self.sell_count as f64);
128            self.last_value = Some(v);
129            Some(v)
130        } else {
131            // Equality breaks both streaks; the bar emits zero.
132            self.buy_count = 0;
133            self.sell_count = 0;
134            self.last_value = Some(0.0);
135            Some(0.0)
136        }
137    }
138
139    fn reset(&mut self) {
140        self.closes.clear();
141        self.buy_count = 0;
142        self.sell_count = 0;
143        self.last_value = None;
144    }
145
146    fn warmup_period(&self) -> usize {
147        self.lookback + 1
148    }
149
150    fn is_ready(&self) -> bool {
151        self.last_value.is_some()
152    }
153
154    fn name(&self) -> &'static str {
155        "TDSetup"
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::traits::BatchExt;
163
164    fn c(close: f64, ts: i64) -> Candle {
165        Candle::new_unchecked(close, close, close, close, 0.0, ts)
166    }
167
168    #[test]
169    fn pure_uptrend_reaches_sell_setup_9() {
170        // Every close is strictly greater than four bars ago, so the sell
171        // streak advances by one per bar from the moment lookback is filled.
172        let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
173        let mut setup = TdSetup::classic();
174        let out = setup.batch(&candles);
175        // Indices 0..4 are warmup. Index 4 is the first bar with a reference.
176        // Sell-setup advances each bar: -1 at idx 4, -2 at idx 5, …, -9 at
177        // idx 12; from there it caps at -9 because target is 9.
178        for (i, v) in out.iter().enumerate().take(4) {
179            assert!(v.is_none(), "index {i} must be None during warmup");
180        }
181        assert_eq!(out[4], Some(-1.0));
182        assert_eq!(out[5], Some(-2.0));
183        assert_eq!(out[12], Some(-9.0));
184        assert_eq!(out[13], Some(-9.0));
185        assert_eq!(out[19], Some(-9.0));
186    }
187
188    #[test]
189    fn pure_downtrend_reaches_buy_setup_9() {
190        let candles: Vec<Candle> = (1..=20)
191            .rev()
192            .enumerate()
193            .map(|(i, v)| c(f64::from(v), i64::try_from(i).unwrap()))
194            .collect();
195        let mut setup = TdSetup::classic();
196        let out = setup.batch(&candles);
197        // Buy streak should mirror the sell case: +1 at idx 4, capping at +9.
198        assert_eq!(out[4], Some(1.0));
199        assert_eq!(out[12], Some(9.0));
200        assert_eq!(out[19], Some(9.0));
201    }
202
203    #[test]
204    fn flat_series_emits_zero_after_warmup() {
205        // Every close equals the reference close (lookback bars earlier), so
206        // neither streak ever advances; the indicator emits 0 every bar.
207        let candles: Vec<Candle> = (0..20).map(|i| c(42.0, i)).collect();
208        let mut setup = TdSetup::classic();
209        let out = setup.batch(&candles);
210        for v in out.iter().skip(4) {
211            assert_eq!(*v, Some(0.0));
212        }
213    }
214
215    #[test]
216    fn streak_resets_on_direction_flip() {
217        // First 4 closes are warmup. Then 4 strictly-lower closes -> buy
218        // streak 1..=4. The next close is higher than its reference -> the
219        // buy streak resets and the sell streak starts at 1.
220        let candles = [
221            c(10.0, 0),
222            c(10.0, 1),
223            c(10.0, 2),
224            c(10.0, 3),
225            c(9.0, 4),
226            c(8.0, 5),
227            c(7.0, 6),
228            c(6.0, 7),
229            c(11.0, 8),
230        ];
231        let mut setup = TdSetup::classic();
232        let out = setup.batch(&candles);
233        assert_eq!(out[4], Some(1.0));
234        assert_eq!(out[7], Some(4.0));
235        assert_eq!(out[8], Some(-1.0));
236    }
237
238    #[test]
239    fn rejects_zero_arguments() {
240        assert!(matches!(TdSetup::new(0, 9), Err(Error::PeriodZero)));
241        assert!(matches!(TdSetup::new(4, 0), Err(Error::PeriodZero)));
242    }
243
244    #[test]
245    fn batch_equals_streaming() {
246        let candles: Vec<Candle> = (0..80)
247            .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0, i64::from(i)))
248            .collect();
249        let mut a = TdSetup::classic();
250        let mut b = TdSetup::classic();
251        assert_eq!(
252            a.batch(&candles),
253            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
254        );
255    }
256
257    #[test]
258    fn reset_clears_state() {
259        let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
260        let mut setup = TdSetup::classic();
261        setup.batch(&candles);
262        assert!(setup.is_ready());
263        setup.reset();
264        assert!(!setup.is_ready());
265        assert_eq!(setup.update(candles[0]), None);
266        assert_eq!(setup.value(), None);
267    }
268
269    #[test]
270    fn accessors_and_metadata() {
271        let setup = TdSetup::new(4, 9).unwrap();
272        assert_eq!(setup.params(), (4, 9));
273        assert_eq!(setup.warmup_period(), 5);
274        assert_eq!(setup.name(), "TDSetup");
275        assert_eq!(setup.value(), None);
276    }
277}