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    ///
40    /// The two levels are established independently, so one can be a real price
41    /// while the other is still `NAN`. `update` withholds the output entirely
42    /// (`None`) until at least one of them exists, so a returned value always
43    /// carries at least one level. `NAN` is the encoding because the C ABI
44    /// mirrors this struct as two plain `double`s.
45    pub resistance: f64,
46    /// Latest TDST support, or `NAN` if no sell setup has completed yet.
47    ///
48    /// See [`Self::resistance`] for when a level can be `NAN`.
49    pub support: f64,
50}
51
52/// TD Lines (TDST) — setup-derived horizontal support / resistance.
53/// # Example
54///
55/// ```
56/// use wickra_core::{TdLines, Candle, Indicator};
57///
58/// let mut indicator = TdLines::new(4, 9).unwrap();
59/// // `None` during warmup, then `Some(_)` once enough bars are seen.
60/// let mut out = None;
61/// for i in 0..40i64 {
62///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
63///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
64///     out = indicator.update(candle);
65/// }
66/// let _ = out;
67/// ```
68#[derive(Debug, Clone)]
69pub struct TdLines {
70    lookback: usize,
71    target: usize,
72    closes: VecDeque<f64>,
73    buy_count: usize,
74    sell_count: usize,
75    /// Highest high observed during the *current* buy-setup run (running
76    /// extreme, resets when the buy run resets).
77    buy_run_max_high: f64,
78    /// Lowest low observed during the *current* sell-setup run.
79    sell_run_min_low: f64,
80    /// Set once a buy setup completes; `None` means no TDST resistance exists
81    /// yet. Kept as an `Option` rather than a `NAN` sentinel so "unset" is not
82    /// inferred from the bit pattern of a value that is supposed to be a price.
83    resistance: Option<f64>,
84    /// Set once a sell setup completes; `None` means no TDST support exists yet.
85    support: Option<f64>,
86    ready: bool,
87}
88
89impl TdLines {
90    /// Construct a TD Lines with explicit lookback and target. The
91    /// canonical DeMark configuration is `lookback = 4`, `target = 9`.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`Error::PeriodZero`] if either argument is zero.
96    pub fn new(lookback: usize, target: usize) -> Result<Self> {
97        if lookback == 0 || target == 0 {
98            return Err(Error::PeriodZero);
99        }
100        Ok(Self {
101            lookback,
102            target,
103            closes: VecDeque::with_capacity(lookback + 1),
104            buy_count: 0,
105            sell_count: 0,
106            buy_run_max_high: f64::NEG_INFINITY,
107            sell_run_min_low: f64::INFINITY,
108            resistance: None,
109            support: None,
110            ready: false,
111        })
112    }
113
114    /// DeMark's classic configuration: `lookback = 4`, `target = 9`.
115    pub fn classic() -> Self {
116        Self::new(4, 9).expect("classic TD Lines parameters are valid")
117    }
118
119    /// Configured `(lookback, target)`.
120    pub const fn params(&self) -> (usize, usize) {
121        (self.lookback, self.target)
122    }
123}
124
125impl Indicator for TdLines {
126    type Input = Candle;
127    type Output = TdLinesOutput;
128
129    fn update(&mut self, candle: Candle) -> Option<TdLinesOutput> {
130        if self.closes.len() > self.lookback {
131            self.closes.pop_front();
132        }
133        if self.closes.len() < self.lookback {
134            self.closes.push_back(candle.close);
135            return None;
136        }
137        let reference = *self.closes.front().expect("non-empty after the guard");
138        self.closes.push_back(candle.close);
139
140        if candle.close < reference {
141            // Continue / start a buy-setup run; if the sell run breaks
142            // here, reset its running extreme.
143            if self.buy_count == 0 {
144                self.buy_run_max_high = candle.high;
145            } else {
146                self.buy_run_max_high = self.buy_run_max_high.max(candle.high);
147            }
148            self.buy_count = (self.buy_count + 1).min(self.target);
149            self.sell_count = 0;
150            self.sell_run_min_low = f64::INFINITY;
151            if self.buy_count == self.target {
152                self.resistance = Some(self.buy_run_max_high);
153            }
154        } else if candle.close > reference {
155            if self.sell_count == 0 {
156                self.sell_run_min_low = candle.low;
157            } else {
158                self.sell_run_min_low = self.sell_run_min_low.min(candle.low);
159            }
160            self.sell_count = (self.sell_count + 1).min(self.target);
161            self.buy_count = 0;
162            self.buy_run_max_high = f64::NEG_INFINITY;
163            if self.sell_count == self.target {
164                self.support = Some(self.sell_run_min_low);
165            }
166        } else {
167            // Equality breaks both runs.
168            self.buy_count = 0;
169            self.sell_count = 0;
170            self.buy_run_max_high = f64::NEG_INFINITY;
171            self.sell_run_min_low = f64::INFINITY;
172        }
173
174        // Neither level established means there is nothing to report yet. The
175        // trait defines `None` as "insufficient inputs to produce a defined
176        // value", and a pair of NaNs is exactly that — on a flat series the old
177        // code emitted it on every bar forever, and it reached the bindings as
178        // two NaNs in a flat output buffer.
179        if self.resistance.is_none() && self.support.is_none() {
180            return None;
181        }
182        self.ready = true;
183        Some(TdLinesOutput {
184            resistance: self.resistance.unwrap_or(f64::NAN),
185            support: self.support.unwrap_or(f64::NAN),
186        })
187    }
188
189    fn reset(&mut self) {
190        self.closes.clear();
191        self.buy_count = 0;
192        self.sell_count = 0;
193        self.buy_run_max_high = f64::NEG_INFINITY;
194        self.sell_run_min_low = f64::INFINITY;
195        self.resistance = None;
196        self.support = None;
197        self.ready = false;
198    }
199
200    /// Lower bound only: a TDST line needs a *completed* setup, which depends on
201    /// the data, so the first value can arrive arbitrarily later than this — and
202    /// on a series that never completes a setup, never.
203    #[inline]
204    fn warmup_period(&self) -> usize {
205        self.lookback + 1
206    }
207
208    #[inline]
209    fn is_ready(&self) -> bool {
210        self.ready
211    }
212
213    #[inline]
214    fn name(&self) -> &'static str {
215        "TDLines"
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::traits::BatchExt;
223    use approx::assert_relative_eq;
224
225    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
226        Candle::new_unchecked(close, high, low, close, 0.0, ts)
227    }
228
229    #[test]
230    fn uptrend_completes_sell_setup_and_sets_support() {
231        // Strictly rising series -> sell setup completes at bar index 12
232        // (warmup 5 + 8 advances). The lowest low across bars 4..=12 is
233        // the low at idx 4 since the series is strictly rising.
234        let candles: Vec<Candle> = (1..=20)
235            .map(|i| {
236                c(
237                    f64::from(i) + 0.5,
238                    f64::from(i) - 0.5,
239                    f64::from(i),
240                    i64::from(i),
241                )
242            })
243            .collect();
244        let mut lines = TdLines::classic();
245        let out = lines.batch(&candles);
246        // Nothing is emitted before a setup completes: neither level exists, so
247        // there is no defined value to report.
248        assert!(out[..12].iter().all(Option::is_none));
249        // After completion at idx 12, support is the low of bar idx 4 = 4.5.
250        // Resistance stays NaN because no buy setup ever completes here.
251        let after = out[12].expect("the completed sell setup emits");
252        assert!(after.resistance.is_nan());
253        assert_relative_eq!(after.support, 4.5, epsilon = 1e-12);
254        // Subsequent bars (still increasing, sell setup saturating) keep
255        // the running extreme at the original low.
256        let final_out = out[19].expect("ready");
257        assert_relative_eq!(final_out.support, 4.5, epsilon = 1e-12);
258    }
259
260    #[test]
261    fn downtrend_completes_buy_setup_and_sets_resistance() {
262        let candles: Vec<Candle> = (1..=20)
263            .rev()
264            .enumerate()
265            .map(|(i, v)| {
266                c(
267                    f64::from(v) + 0.5,
268                    f64::from(v) - 0.5,
269                    f64::from(v),
270                    i64::try_from(i).unwrap(),
271                )
272            })
273            .collect();
274        let mut lines = TdLines::classic();
275        let out = lines.batch(&candles);
276        // Buy setup completes at idx 12. The highest high during the
277        // buy run is the high of bar idx 4 (since the series is strictly
278        // decreasing): low/high of bar 4 are computed below.
279        let after = out[12].expect("ready");
280        assert!(after.support.is_nan());
281        // The high at idx 4 in the reversed series is value 16 + 0.5.
282        assert_relative_eq!(after.resistance, 16.5, epsilon = 1e-12);
283    }
284
285    #[test]
286    fn flat_series_never_emits() {
287        // All closes equal -> neither setup advances -> no level ever exists, so
288        // nothing is emitted. This used to yield `Some` with two NaNs on every
289        // bar past the warmup.
290        let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
291        let mut lines = TdLines::classic();
292        let out = lines.batch(&candles);
293        assert!(out.iter().all(Option::is_none));
294        assert!(!lines.is_ready());
295    }
296
297    #[test]
298    fn batch_equals_streaming() {
299        let candles: Vec<Candle> = (0..80)
300            .map(|i| {
301                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
302                c(m + 1.0, m - 1.0, m, i64::from(i))
303            })
304            .collect();
305        let mut a = TdLines::classic();
306        let mut b = TdLines::classic();
307        let av = a.batch(&candles);
308        let bv: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
309        assert_eq!(av.len(), bv.len());
310        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
311            assert_eq!(x.is_some(), y.is_some(), "row {i} option mismatch");
312            if let (Some(a), Some(b)) = (x, y) {
313                assert_eq!(
314                    a.support.is_nan(),
315                    b.support.is_nan(),
316                    "row {i} support nan flag"
317                );
318                assert_eq!(
319                    a.resistance.is_nan(),
320                    b.resistance.is_nan(),
321                    "row {i} resistance nan flag"
322                );
323                if !a.support.is_nan() {
324                    assert_relative_eq!(a.support, b.support, epsilon = 1e-12);
325                }
326                if !a.resistance.is_nan() {
327                    assert_relative_eq!(a.resistance, b.resistance, epsilon = 1e-12);
328                }
329            }
330        }
331    }
332
333    #[test]
334    fn rejects_invalid_params() {
335        assert!(matches!(TdLines::new(0, 9), Err(Error::PeriodZero)));
336        assert!(matches!(TdLines::new(4, 0), Err(Error::PeriodZero)));
337    }
338
339    #[test]
340    fn reset_clears_state() {
341        let candles: Vec<Candle> = (1..=20)
342            .map(|i| {
343                c(
344                    f64::from(i) + 0.5,
345                    f64::from(i) - 0.5,
346                    f64::from(i),
347                    i64::from(i),
348                )
349            })
350            .collect();
351        let mut lines = TdLines::classic();
352        lines.batch(&candles);
353        assert!(lines.is_ready());
354        lines.reset();
355        assert!(!lines.is_ready());
356        assert_eq!(lines.update(candles[0]), None);
357    }
358
359    #[test]
360    fn accessors_and_metadata() {
361        let lines = TdLines::classic();
362        assert_eq!(lines.params(), (4, 9));
363        assert_eq!(lines.warmup_period(), 5);
364        assert_eq!(lines.name(), "TDLines");
365    }
366}