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}