wickra_core/indicators/td_sequential.rs
1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Sequential (Setup + Countdown).
4//!
5//! TD Sequential is DeMark's flagship two-phase exhaustion pattern:
6//!
7//! 1. **Setup phase** — 9 consecutive bars whose close is less-than (buy
8//! setup) or greater-than (sell setup) the close 4 bars earlier. The
9//! setup *completes* on the 9th bar.
10//! 2. **Countdown phase** — after a completed setup, count up to 13 bars
11//! that satisfy the countdown comparison (buy countdown: `close <= low`
12//! two bars earlier; sell countdown: `close >= high` two bars earlier).
13//! Countdown bars do not need to be consecutive.
14//!
15//! A completed countdown (13) signals exhaustion in the direction of the
16//! original setup and is the canonical DeMark reversal signal.
17//!
18//! Output struct `TdSequentialOutput`:
19//!
20//! - `setup`: signed setup count (positive for buy setup, negative for sell
21//! setup, 0 when no streak is active; capped at ±9).
22//! - `countdown`: signed countdown count (positive for buy countdown, negative
23//! for sell countdown, 0 when no countdown is active; capped at ±13).
24//! - `direction`: `+1.0` if a buy countdown is currently active, `-1.0` if a
25//! sell countdown is active, `0.0` otherwise. The countdown direction is
26//! set when the originating setup completes and stays valid until the
27//! countdown finishes or is invalidated by an opposite-direction setup.
28
29use std::collections::VecDeque;
30
31use crate::error::{Error, Result};
32use crate::ohlcv::Candle;
33use crate::traits::Indicator;
34
35/// Direction of an active TD Sequential countdown phase.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Direction {
38 None,
39 Buy,
40 Sell,
41}
42
43/// Output of [`TdSequential`]: setup count, countdown count, and active
44/// countdown direction.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct TdSequentialOutput {
47 /// Signed setup count: +N for an active buy setup of length `N`, −N for
48 /// a sell setup of length `N`, 0 if neither streak is active. Capped at
49 /// ±9 (the canonical setup target).
50 pub setup: f64,
51 /// Signed countdown count: +N for an active buy countdown of length `N`,
52 /// −N for a sell countdown of length `N`, 0 if no countdown is active.
53 /// Capped at ±13.
54 pub countdown: f64,
55 /// Direction of the active countdown: `+1.0` for buy, `−1.0` for sell,
56 /// `0.0` if no countdown is currently active.
57 pub direction: f64,
58}
59
60/// TD Sequential state machine: combined Setup (1-9) + Countdown (1-13).
61/// # Example
62///
63/// ```
64/// use wickra_core::{TdSequential, Candle, Indicator};
65///
66/// let mut indicator = TdSequential::new(4, 9, 2, 13).unwrap();
67/// // `None` during warmup, then `Some(_)` once enough bars are seen.
68/// let mut out = None;
69/// for i in 0..40i64 {
70/// let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
71/// let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
72/// out = indicator.update(candle);
73/// }
74/// let _ = out;
75/// ```
76#[derive(Debug, Clone)]
77pub struct TdSequential {
78 // Rolling window of recent candles. We need up to 5 closes back (for the
79 // setup rule which compares close[i] vs close[i-4]) and the high/low from
80 // 2 bars ago (for the countdown rule).
81 candles: VecDeque<Candle>,
82 setup_lookback: usize,
83 setup_target: usize,
84 countdown_lookback: usize,
85 countdown_target: usize,
86 buy_setup: usize,
87 sell_setup: usize,
88 buy_countdown: usize,
89 sell_countdown: usize,
90 countdown_dir: Direction,
91 ready: bool,
92}
93
94impl TdSequential {
95 /// Construct a TD Sequential with explicit lookbacks and targets. The
96 /// canonical DeMark configuration is `setup_lookback = 4`, `setup_target =
97 /// 9`, `countdown_lookback = 2`, `countdown_target = 13`.
98 ///
99 /// # Errors
100 ///
101 /// Returns [`Error::PeriodZero`] if any argument is zero.
102 pub fn new(
103 setup_lookback: usize,
104 setup_target: usize,
105 countdown_lookback: usize,
106 countdown_target: usize,
107 ) -> Result<Self> {
108 if setup_lookback == 0
109 || setup_target == 0
110 || countdown_lookback == 0
111 || countdown_target == 0
112 {
113 return Err(Error::PeriodZero);
114 }
115 // Need to keep enough candles for both rules: setup uses close[-N];
116 // countdown uses high/low[-M]. Reserve `max(N, M) + 1` slots.
117 let cap = setup_lookback.max(countdown_lookback) + 1;
118 Ok(Self {
119 candles: VecDeque::with_capacity(cap),
120 setup_lookback,
121 setup_target,
122 countdown_lookback,
123 countdown_target,
124 buy_setup: 0,
125 sell_setup: 0,
126 buy_countdown: 0,
127 sell_countdown: 0,
128 countdown_dir: Direction::None,
129 ready: false,
130 })
131 }
132
133 /// DeMark's classic configuration: setup `lookback = 4, target = 9`,
134 /// countdown `lookback = 2, target = 13`.
135 pub fn classic() -> Self {
136 Self::new(4, 9, 2, 13).expect("classic TD Sequential parameters are valid")
137 }
138
139 /// Configured `(setup_lookback, setup_target, countdown_lookback,
140 /// countdown_target)`.
141 pub const fn params(&self) -> (usize, usize, usize, usize) {
142 (
143 self.setup_lookback,
144 self.setup_target,
145 self.countdown_lookback,
146 self.countdown_target,
147 )
148 }
149}
150
151impl Indicator for TdSequential {
152 type Input = Candle;
153 type Output = TdSequentialOutput;
154
155 fn update(&mut self, candle: Candle) -> Option<TdSequentialOutput> {
156 let cap = self.setup_lookback.max(self.countdown_lookback) + 1;
157 if self.candles.len() == cap {
158 self.candles.pop_front();
159 }
160 // The required minimum history is `max(setup_lookback,
161 // countdown_lookback)` previous bars. Once we have that many, we can
162 // evaluate both rules.
163 let need = self.setup_lookback.max(self.countdown_lookback);
164 if self.candles.len() < need {
165 self.candles.push_back(candle);
166 return None;
167 }
168
169 // --- Setup rule: compare to close[setup_lookback bars ago] ---
170 // After `need` candles are buffered, the candle at offset `need - L`
171 // from the front is the one `L` bars before the new candle (0-based
172 // count: `front()` is `need` bars ago).
173 let setup_ref_idx = need - self.setup_lookback;
174 let setup_ref_close = self.candles[setup_ref_idx].close;
175
176 if candle.close < setup_ref_close {
177 self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
178 self.sell_setup = 0;
179 } else if candle.close > setup_ref_close {
180 self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
181 self.buy_setup = 0;
182 } else {
183 self.buy_setup = 0;
184 self.sell_setup = 0;
185 }
186
187 // --- Countdown activation: when a setup completes, arm the countdown
188 // in the same direction; an opposite-direction setup invalidates any
189 // active countdown.
190 if self.buy_setup == self.setup_target {
191 if self.countdown_dir != Direction::Buy {
192 self.buy_countdown = 0;
193 self.sell_countdown = 0;
194 }
195 self.countdown_dir = Direction::Buy;
196 } else if self.sell_setup == self.setup_target {
197 if self.countdown_dir != Direction::Sell {
198 self.buy_countdown = 0;
199 self.sell_countdown = 0;
200 }
201 self.countdown_dir = Direction::Sell;
202 }
203
204 // --- Countdown rule: compare close to high/low `countdown_lookback`
205 // bars ago. Only the active direction advances. Once a countdown
206 // reaches `countdown_target`, the strict `< countdown_target` guard
207 // keeps it pinned so the caller can detect the "13" signal on this
208 // bar and any subsequent bar until a new setup arms a fresh run.
209 let cd_ref_idx = need - self.countdown_lookback;
210 let cd_ref = &self.candles[cd_ref_idx];
211 match self.countdown_dir {
212 Direction::Buy => {
213 if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target {
214 self.buy_countdown += 1;
215 }
216 }
217 Direction::Sell => {
218 if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target {
219 self.sell_countdown += 1;
220 }
221 }
222 Direction::None => {}
223 }
224
225 self.candles.push_back(candle);
226 self.ready = true;
227
228 let setup = if self.buy_setup > 0 {
229 self.buy_setup as f64
230 } else if self.sell_setup > 0 {
231 -(self.sell_setup as f64)
232 } else {
233 0.0
234 };
235 let (countdown, direction) = match self.countdown_dir {
236 Direction::Buy => (self.buy_countdown as f64, 1.0),
237 Direction::Sell => (-(self.sell_countdown as f64), -1.0),
238 Direction::None => (0.0, 0.0),
239 };
240
241 Some(TdSequentialOutput {
242 setup,
243 countdown,
244 direction,
245 })
246 }
247
248 fn reset(&mut self) {
249 self.candles.clear();
250 self.buy_setup = 0;
251 self.sell_setup = 0;
252 self.buy_countdown = 0;
253 self.sell_countdown = 0;
254 self.countdown_dir = Direction::None;
255 self.ready = false;
256 }
257
258 #[inline]
259 fn warmup_period(&self) -> usize {
260 self.setup_lookback.max(self.countdown_lookback) + 1
261 }
262
263 #[inline]
264 fn is_ready(&self) -> bool {
265 self.ready
266 }
267
268 #[inline]
269 fn name(&self) -> &'static str {
270 "TDSequential"
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::traits::BatchExt;
278
279 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
280 Candle::new_unchecked(close, high, low, close, 0.0, ts)
281 }
282
283 #[test]
284 fn pure_uptrend_completes_sell_setup_then_progresses_countdown() {
285 // Strictly increasing closes -> sell setup increments every bar past
286 // warmup, reaching -9 by index 12 (warmup is 4 + 1). After that,
287 // every bar continues to make a higher close, so each subsequent bar
288 // also makes a higher close than the high 2 bars ago — the sell
289 // countdown increments on each bar after activation.
290 let candles: Vec<Candle> = (1..=40)
291 .map(|i| {
292 c(
293 f64::from(i) + 0.5,
294 f64::from(i) - 0.5,
295 f64::from(i),
296 i64::from(i),
297 )
298 })
299 .collect();
300 let mut td = TdSequential::classic();
301 let out = td.batch(&candles);
302
303 // Warmup: indices 0..3 yield None (need=4 prior closes).
304 for v in out.iter().take(4) {
305 assert!(v.is_none());
306 }
307 // After index 12, setup reaches -9 (completed). From the next bar on,
308 // countdown begins to increment.
309 let at_12 = out[12].expect("setup ready");
310 assert_eq!(at_12.setup, -9.0);
311 assert_eq!(at_12.direction, -1.0); // countdown direction armed
312
313 // Each subsequent bar makes close > high[i-2], so the sell countdown
314 // advances by one per bar; by some later index it caps at -13.
315 let later = out[30].expect("ready");
316 assert_eq!(later.direction, -1.0);
317 assert_eq!(later.countdown, -13.0);
318 }
319
320 #[test]
321 fn pure_downtrend_completes_buy_setup_then_progresses_countdown() {
322 // Strictly decreasing closes -> buy setup increments every bar past
323 // warmup, reaching 9 by index 12. After activation, every subsequent
324 // bar satisfies close <= low[i-2], so the buy countdown advances by
325 // one per bar and pins at +13.
326 let candles: Vec<Candle> = (1..=40)
327 .rev()
328 .enumerate()
329 .map(|(k, i)| {
330 c(
331 f64::from(i) + 0.5,
332 f64::from(i) - 0.5,
333 f64::from(i),
334 i64::try_from(k).unwrap(),
335 )
336 })
337 .collect();
338 let mut td = TdSequential::classic();
339 let out = td.batch(&candles);
340
341 // Warmup: indices 0..3 yield None.
342 for v in out.iter().take(4) {
343 assert!(v.is_none());
344 }
345 let at_12 = out[12].expect("setup ready");
346 assert_eq!(at_12.setup, 9.0);
347 assert_eq!(at_12.direction, 1.0); // buy direction armed
348
349 // By idx 30 the buy countdown has saturated at +13.
350 let later = out[30].expect("ready");
351 assert_eq!(later.direction, 1.0);
352 assert_eq!(later.countdown, 13.0);
353 }
354
355 #[test]
356 fn flat_series_emits_zero_setup_and_no_countdown() {
357 // All closes equal -> never completes any setup; countdown never
358 // activates; setup, countdown, direction all stay at 0.
359 let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
360 let mut td = TdSequential::classic();
361 let out = td.batch(&candles);
362 for v in out.iter().skip(5) {
363 let o = v.expect("ready post-warmup");
364 assert_eq!(o.setup, 0.0);
365 assert_eq!(o.countdown, 0.0);
366 assert_eq!(o.direction, 0.0);
367 }
368 }
369
370 #[test]
371 fn batch_equals_streaming() {
372 let candles: Vec<Candle> = (0..60)
373 .map(|i| {
374 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
375 c(m + 1.0, m - 1.0, m, i64::from(i))
376 })
377 .collect();
378 let mut a = TdSequential::classic();
379 let mut b = TdSequential::classic();
380 assert_eq!(
381 a.batch(&candles),
382 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
383 );
384 }
385
386 #[test]
387 fn rejects_invalid_params() {
388 assert!(matches!(
389 TdSequential::new(0, 9, 2, 13),
390 Err(Error::PeriodZero)
391 ));
392 assert!(matches!(
393 TdSequential::new(4, 0, 2, 13),
394 Err(Error::PeriodZero)
395 ));
396 assert!(matches!(
397 TdSequential::new(4, 9, 0, 13),
398 Err(Error::PeriodZero)
399 ));
400 assert!(matches!(
401 TdSequential::new(4, 9, 2, 0),
402 Err(Error::PeriodZero)
403 ));
404 }
405
406 #[test]
407 fn reset_clears_state() {
408 let candles: Vec<Candle> = (1..=20)
409 .map(|i| {
410 c(
411 f64::from(i) + 0.5,
412 f64::from(i) - 0.5,
413 f64::from(i),
414 i64::from(i),
415 )
416 })
417 .collect();
418 let mut td = TdSequential::classic();
419 td.batch(&candles);
420 assert!(td.is_ready());
421 td.reset();
422 assert!(!td.is_ready());
423 assert_eq!(td.update(candles[0]), None);
424 }
425
426 #[test]
427 fn accessors_and_metadata() {
428 let td = TdSequential::classic();
429 assert_eq!(td.params(), (4, 9, 2, 13));
430 assert_eq!(td.warmup_period(), 5);
431 assert_eq!(td.name(), "TDSequential");
432 }
433}