Skip to main content

wickra_core/indicators/
td_lines.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Lines (TDST — TD Setup Trend Support / Resistance levels).
4//!
5//! Once a TD Setup completes in either direction, DeMark defines two
6//! horizontal trend levels derived from the nine bars of that setup:
7//!
8//! - **TDST resistance** is the highest high among the nine bars of the
9//!   most-recently-completed **buy** setup. A break above resistance
10//!   invalidates the setup's bullish reversal thesis.
11//! - **TDST support** is the lowest low among the nine bars of the
12//!   most-recently-completed **sell** setup. A break below support
13//!   invalidates the setup's bearish reversal thesis.
14//!
15//! Until a setup completes in a given direction, the corresponding level
16//! is `f64::NAN` (no level defined). Once a level is set it stays at its
17//! value until the next completed setup in that direction updates it.
18//!
19//! This implementation tracks both the buy and sell setup state machines
20//! in parallel (sharing the same `lookback` / `target` parameters as
21//! [`crate::TdSetup`]) and records the bar extremes during the active
22//! streak so the level can be emitted the moment the setup completes.
23
24use std::collections::VecDeque;
25
26use crate::error::{Error, Result};
27use crate::ohlcv::Candle;
28use crate::traits::Indicator;
29
30/// Output of [`TdLines`]: the latest TDST resistance / support pair.
31///
32/// `resistance` is set after a completed buy setup (the highest high of
33/// the nine setup bars); `support` is set after a completed sell setup
34/// (the lowest low of the nine setup bars). Either field is `f64::NAN`
35/// until the first setup in that direction completes.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct TdLinesOutput {
38    /// Latest TDST resistance, or `NAN` if no buy setup has completed yet.
39    pub resistance: f64,
40    /// Latest TDST support, or `NAN` if no sell setup has completed yet.
41    pub support: f64,
42}
43
44/// TD Lines (TDST) — setup-derived horizontal support / resistance.
45/// # Example
46///
47/// ```
48/// use wickra_core::{TdLines, Candle, Indicator};
49///
50/// let mut indicator = TdLines::new(4, 9).unwrap();
51/// // `None` during warmup, then `Some(_)` once enough bars are seen.
52/// let mut out = None;
53/// for i in 0..40i64 {
54///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
55///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
56///     out = indicator.update(candle);
57/// }
58/// let _ = out;
59/// ```
60#[derive(Debug, Clone)]
61pub struct TdLines {
62    lookback: usize,
63    target: usize,
64    closes: VecDeque<f64>,
65    buy_count: usize,
66    sell_count: usize,
67    /// Highest high observed during the *current* buy-setup run (running
68    /// extreme, resets when the buy run resets).
69    buy_run_max_high: f64,
70    /// Lowest low observed during the *current* sell-setup run.
71    sell_run_min_low: f64,
72    resistance: f64,
73    support: f64,
74    ready: bool,
75}
76
77impl TdLines {
78    /// Construct a TD Lines with explicit lookback and target. The
79    /// canonical DeMark configuration is `lookback = 4`, `target = 9`.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`Error::PeriodZero`] if either argument is zero.
84    pub fn new(lookback: usize, target: usize) -> Result<Self> {
85        if lookback == 0 || target == 0 {
86            return Err(Error::PeriodZero);
87        }
88        Ok(Self {
89            lookback,
90            target,
91            closes: VecDeque::with_capacity(lookback + 1),
92            buy_count: 0,
93            sell_count: 0,
94            buy_run_max_high: f64::NEG_INFINITY,
95            sell_run_min_low: f64::INFINITY,
96            resistance: f64::NAN,
97            support: f64::NAN,
98            ready: false,
99        })
100    }
101
102    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
103    pub fn classic() -> Self {
104        Self::new(4, 9).expect("classic TD Lines parameters are valid")
105    }
106
107    /// Configured `(lookback, target)`.
108    pub const fn params(&self) -> (usize, usize) {
109        (self.lookback, self.target)
110    }
111}
112
113impl Indicator for TdLines {
114    type Input = Candle;
115    type Output = TdLinesOutput;
116
117    fn update(&mut self, candle: Candle) -> Option<TdLinesOutput> {
118        if self.closes.len() > self.lookback {
119            self.closes.pop_front();
120        }
121        if self.closes.len() < self.lookback {
122            self.closes.push_back(candle.close);
123            return None;
124        }
125        let reference = *self.closes.front().expect("non-empty after the guard");
126        self.closes.push_back(candle.close);
127
128        if candle.close < reference {
129            // Continue / start a buy-setup run; if the sell run breaks
130            // here, reset its running extreme.
131            if self.buy_count == 0 {
132                self.buy_run_max_high = candle.high;
133            } else {
134                self.buy_run_max_high = self.buy_run_max_high.max(candle.high);
135            }
136            self.buy_count = (self.buy_count + 1).min(self.target);
137            self.sell_count = 0;
138            self.sell_run_min_low = f64::INFINITY;
139            if self.buy_count == self.target {
140                self.resistance = self.buy_run_max_high;
141            }
142        } else if candle.close > reference {
143            if self.sell_count == 0 {
144                self.sell_run_min_low = candle.low;
145            } else {
146                self.sell_run_min_low = self.sell_run_min_low.min(candle.low);
147            }
148            self.sell_count = (self.sell_count + 1).min(self.target);
149            self.buy_count = 0;
150            self.buy_run_max_high = f64::NEG_INFINITY;
151            if self.sell_count == self.target {
152                self.support = self.sell_run_min_low;
153            }
154        } else {
155            // Equality breaks both runs.
156            self.buy_count = 0;
157            self.sell_count = 0;
158            self.buy_run_max_high = f64::NEG_INFINITY;
159            self.sell_run_min_low = f64::INFINITY;
160        }
161
162        self.ready = true;
163        Some(TdLinesOutput {
164            resistance: self.resistance,
165            support: self.support,
166        })
167    }
168
169    fn reset(&mut self) {
170        self.closes.clear();
171        self.buy_count = 0;
172        self.sell_count = 0;
173        self.buy_run_max_high = f64::NEG_INFINITY;
174        self.sell_run_min_low = f64::INFINITY;
175        self.resistance = f64::NAN;
176        self.support = f64::NAN;
177        self.ready = false;
178    }
179
180    fn warmup_period(&self) -> usize {
181        self.lookback + 1
182    }
183
184    fn is_ready(&self) -> bool {
185        self.ready
186    }
187
188    fn name(&self) -> &'static str {
189        "TDLines"
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::traits::BatchExt;
197    use approx::assert_relative_eq;
198
199    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
200        Candle::new_unchecked(close, high, low, close, 0.0, ts)
201    }
202
203    #[test]
204    fn uptrend_completes_sell_setup_and_sets_support() {
205        // Strictly rising series -> sell setup completes at bar index 12
206        // (warmup 5 + 8 advances). The lowest low across bars 4..=12 is
207        // the low at idx 4 since the series is strictly rising.
208        let candles: Vec<Candle> = (1..=20)
209            .map(|i| {
210                c(
211                    f64::from(i) + 0.5,
212                    f64::from(i) - 0.5,
213                    f64::from(i),
214                    i64::from(i),
215                )
216            })
217            .collect();
218        let mut lines = TdLines::classic();
219        let out = lines.batch(&candles);
220        // Before completion, support is NaN; resistance is NaN throughout
221        // (no buy setup ever completes).
222        let early = out[5].expect("ready");
223        assert!(early.support.is_nan());
224        assert!(early.resistance.is_nan());
225        // After completion at idx 12, support is the low of bar idx 4 = 4.5.
226        let after = out[12].expect("ready");
227        assert!(after.resistance.is_nan());
228        assert_relative_eq!(after.support, 4.5, epsilon = 1e-12);
229        // Subsequent bars (still increasing, sell setup saturating) keep
230        // the running extreme at the original low.
231        let final_out = out[19].expect("ready");
232        assert_relative_eq!(final_out.support, 4.5, epsilon = 1e-12);
233    }
234
235    #[test]
236    fn downtrend_completes_buy_setup_and_sets_resistance() {
237        let candles: Vec<Candle> = (1..=20)
238            .rev()
239            .enumerate()
240            .map(|(i, v)| {
241                c(
242                    f64::from(v) + 0.5,
243                    f64::from(v) - 0.5,
244                    f64::from(v),
245                    i64::try_from(i).unwrap(),
246                )
247            })
248            .collect();
249        let mut lines = TdLines::classic();
250        let out = lines.batch(&candles);
251        // Buy setup completes at idx 12. The highest high during the
252        // buy run is the high of bar idx 4 (since the series is strictly
253        // decreasing): low/high of bar 4 are computed below.
254        let after = out[12].expect("ready");
255        assert!(after.support.is_nan());
256        // The high at idx 4 in the reversed series is value 16 + 0.5.
257        assert_relative_eq!(after.resistance, 16.5, epsilon = 1e-12);
258    }
259
260    #[test]
261    fn flat_series_never_sets_levels() {
262        // All closes equal -> neither setup advances -> both levels stay NaN.
263        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
264        let mut lines = TdLines::classic();
265        for v in lines.batch(&candles).into_iter().flatten() {
266            assert!(v.support.is_nan());
267            assert!(v.resistance.is_nan());
268        }
269    }
270
271    #[test]
272    fn batch_equals_streaming() {
273        let candles: Vec<Candle> = (0..80)
274            .map(|i| {
275                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
276                c(m + 1.0, m - 1.0, m, i64::from(i))
277            })
278            .collect();
279        let mut a = TdLines::classic();
280        let mut b = TdLines::classic();
281        let av = a.batch(&candles);
282        let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
283        assert_eq!(av.len(), bv.len());
284        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
285            assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch");
286            if let (Some(a), Some(b)) = (x, y) {
287                assert_eq!(
288                    a.support.is_nan(),
289                    b.support.is_nan(),
290                    "row {i} support nan flag"
291                );
292                assert_eq!(
293                    a.resistance.is_nan(),
294                    b.resistance.is_nan(),
295                    "row {i} resistance nan flag"
296                );
297                if !a.support.is_nan() {
298                    assert_relative_eq!(a.support, b.support, epsilon = 1e-12);
299                }
300                if !a.resistance.is_nan() {
301                    assert_relative_eq!(a.resistance, b.resistance, epsilon = 1e-12);
302                }
303            }
304        }
305    }
306
307    #[test]
308    fn rejects_invalid_params() {
309        assert!(matches!(TdLines::new(0, 9), Err(Error::PeriodZero)));
310        assert!(matches!(TdLines::new(4, 0), Err(Error::PeriodZero)));
311    }
312
313    #[test]
314    fn reset_clears_state() {
315        let candles: Vec<Candle> = (1..=20)
316            .map(|i| {
317                c(
318                    f64::from(i) + 0.5,
319                    f64::from(i) - 0.5,
320                    f64::from(i),
321                    i64::from(i),
322                )
323            })
324            .collect();
325        let mut lines = TdLines::classic();
326        lines.batch(&candles);
327        assert!(lines.is_ready());
328        lines.reset();
329        assert!(!lines.is_ready());
330        assert_eq!(lines.update(candles[0]), None);
331    }
332
333    #[test]
334    fn accessors_and_metadata() {
335        let lines = TdLines::classic();
336        assert_eq!(lines.params(), (4, 9));
337        assert_eq!(lines.warmup_period(), 5);
338        assert_eq!(lines.name(), "TDLines");
339    }
340}