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#[derive(Debug, Clone)]
46pub struct TdLines {
47    lookback: usize,
48    target: usize,
49    closes: VecDeque<f64>,
50    buy_count: usize,
51    sell_count: usize,
52    /// Highest high observed during the *current* buy-setup run (running
53    /// extreme, resets when the buy run resets).
54    buy_run_max_high: f64,
55    /// Lowest low observed during the *current* sell-setup run.
56    sell_run_min_low: f64,
57    resistance: f64,
58    support: f64,
59    ready: bool,
60}
61
62impl TdLines {
63    /// Construct a TD Lines with explicit lookback and target. The
64    /// canonical DeMark configuration is `lookback = 4`, `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            buy_run_max_high: f64::NEG_INFINITY,
80            sell_run_min_low: f64::INFINITY,
81            resistance: f64::NAN,
82            support: f64::NAN,
83            ready: false,
84        })
85    }
86
87    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
88    pub fn classic() -> Self {
89        Self::new(4, 9).expect("classic TD Lines parameters are valid")
90    }
91
92    /// Configured `(lookback, target)`.
93    pub const fn params(&self) -> (usize, usize) {
94        (self.lookback, self.target)
95    }
96}
97
98impl Indicator for TdLines {
99    type Input = Candle;
100    type Output = TdLinesOutput;
101
102    fn update(&mut self, candle: Candle) -> Option<TdLinesOutput> {
103        if self.closes.len() > self.lookback {
104            self.closes.pop_front();
105        }
106        if self.closes.len() < self.lookback {
107            self.closes.push_back(candle.close);
108            return None;
109        }
110        let reference = *self.closes.front().expect("non-empty after the guard");
111        self.closes.push_back(candle.close);
112
113        if candle.close < reference {
114            // Continue / start a buy-setup run; if the sell run breaks
115            // here, reset its running extreme.
116            if self.buy_count == 0 {
117                self.buy_run_max_high = candle.high;
118            } else {
119                self.buy_run_max_high = self.buy_run_max_high.max(candle.high);
120            }
121            self.buy_count = (self.buy_count + 1).min(self.target);
122            self.sell_count = 0;
123            self.sell_run_min_low = f64::INFINITY;
124            if self.buy_count == self.target {
125                self.resistance = self.buy_run_max_high;
126            }
127        } else if candle.close > reference {
128            if self.sell_count == 0 {
129                self.sell_run_min_low = candle.low;
130            } else {
131                self.sell_run_min_low = self.sell_run_min_low.min(candle.low);
132            }
133            self.sell_count = (self.sell_count + 1).min(self.target);
134            self.buy_count = 0;
135            self.buy_run_max_high = f64::NEG_INFINITY;
136            if self.sell_count == self.target {
137                self.support = self.sell_run_min_low;
138            }
139        } else {
140            // Equality breaks both runs.
141            self.buy_count = 0;
142            self.sell_count = 0;
143            self.buy_run_max_high = f64::NEG_INFINITY;
144            self.sell_run_min_low = f64::INFINITY;
145        }
146
147        self.ready = true;
148        Some(TdLinesOutput {
149            resistance: self.resistance,
150            support: self.support,
151        })
152    }
153
154    fn reset(&mut self) {
155        self.closes.clear();
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        self.resistance = f64::NAN;
161        self.support = f64::NAN;
162        self.ready = false;
163    }
164
165    fn warmup_period(&self) -> usize {
166        self.lookback + 1
167    }
168
169    fn is_ready(&self) -> bool {
170        self.ready
171    }
172
173    fn name(&self) -> &'static str {
174        "TDLines"
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::traits::BatchExt;
182    use approx::assert_relative_eq;
183
184    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
185        Candle::new_unchecked(close, high, low, close, 0.0, ts)
186    }
187
188    #[test]
189    fn uptrend_completes_sell_setup_and_sets_support() {
190        // Strictly rising series -> sell setup completes at bar index 12
191        // (warmup 5 + 8 advances). The lowest low across bars 4..=12 is
192        // the low at idx 4 since the series is strictly rising.
193        let candles: Vec<Candle> = (1..=20)
194            .map(|i| {
195                c(
196                    f64::from(i) + 0.5,
197                    f64::from(i) - 0.5,
198                    f64::from(i),
199                    i64::from(i),
200                )
201            })
202            .collect();
203        let mut lines = TdLines::classic();
204        let out = lines.batch(&candles);
205        // Before completion, support is NaN; resistance is NaN throughout
206        // (no buy setup ever completes).
207        let early = out[5].expect("ready");
208        assert!(early.support.is_nan());
209        assert!(early.resistance.is_nan());
210        // After completion at idx 12, support is the low of bar idx 4 = 4.5.
211        let after = out[12].expect("ready");
212        assert!(after.resistance.is_nan());
213        assert_relative_eq!(after.support, 4.5, epsilon = 1e-12);
214        // Subsequent bars (still increasing, sell setup saturating) keep
215        // the running extreme at the original low.
216        let final_out = out[19].expect("ready");
217        assert_relative_eq!(final_out.support, 4.5, epsilon = 1e-12);
218    }
219
220    #[test]
221    fn downtrend_completes_buy_setup_and_sets_resistance() {
222        let candles: Vec<Candle> = (1..=20)
223            .rev()
224            .enumerate()
225            .map(|(i, v)| {
226                c(
227                    f64::from(v) + 0.5,
228                    f64::from(v) - 0.5,
229                    f64::from(v),
230                    i64::try_from(i).unwrap(),
231                )
232            })
233            .collect();
234        let mut lines = TdLines::classic();
235        let out = lines.batch(&candles);
236        // Buy setup completes at idx 12. The highest high during the
237        // buy run is the high of bar idx 4 (since the series is strictly
238        // decreasing): low/high of bar 4 are computed below.
239        let after = out[12].expect("ready");
240        assert!(after.support.is_nan());
241        // The high at idx 4 in the reversed series is value 16 + 0.5.
242        assert_relative_eq!(after.resistance, 16.5, epsilon = 1e-12);
243    }
244
245    #[test]
246    fn flat_series_never_sets_levels() {
247        // All closes equal -> neither setup advances -> both levels stay NaN.
248        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
249        let mut lines = TdLines::classic();
250        for v in lines.batch(&candles).into_iter().flatten() {
251            assert!(v.support.is_nan());
252            assert!(v.resistance.is_nan());
253        }
254    }
255
256    #[test]
257    fn batch_equals_streaming() {
258        let candles: Vec<Candle> = (0..80)
259            .map(|i| {
260                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
261                c(m + 1.0, m - 1.0, m, i64::from(i))
262            })
263            .collect();
264        let mut a = TdLines::classic();
265        let mut b = TdLines::classic();
266        let av = a.batch(&candles);
267        let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
268        assert_eq!(av.len(), bv.len());
269        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
270            assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch");
271            if let (Some(a), Some(b)) = (x, y) {
272                assert_eq!(
273                    a.support.is_nan(),
274                    b.support.is_nan(),
275                    "row {i} support nan flag"
276                );
277                assert_eq!(
278                    a.resistance.is_nan(),
279                    b.resistance.is_nan(),
280                    "row {i} resistance nan flag"
281                );
282                if !a.support.is_nan() {
283                    assert_relative_eq!(a.support, b.support, epsilon = 1e-12);
284                }
285                if !a.resistance.is_nan() {
286                    assert_relative_eq!(a.resistance, b.resistance, epsilon = 1e-12);
287                }
288            }
289        }
290    }
291
292    #[test]
293    fn rejects_invalid_params() {
294        assert!(matches!(TdLines::new(0, 9), Err(Error::PeriodZero)));
295        assert!(matches!(TdLines::new(4, 0), Err(Error::PeriodZero)));
296    }
297
298    #[test]
299    fn reset_clears_state() {
300        let candles: Vec<Candle> = (1..=20)
301            .map(|i| {
302                c(
303                    f64::from(i) + 0.5,
304                    f64::from(i) - 0.5,
305                    f64::from(i),
306                    i64::from(i),
307                )
308            })
309            .collect();
310        let mut lines = TdLines::classic();
311        lines.batch(&candles);
312        assert!(lines.is_ready());
313        lines.reset();
314        assert!(!lines.is_ready());
315        assert_eq!(lines.update(candles[0]), None);
316    }
317
318    #[test]
319    fn accessors_and_metadata() {
320        let lines = TdLines::classic();
321        assert_eq!(lines.params(), (4, 9));
322        assert_eq!(lines.warmup_period(), 5);
323        assert_eq!(lines.name(), "TDLines");
324    }
325}