1#![allow(clippy::doc_markdown)]
2
3use std::collections::VecDeque;
30
31use crate::error::{Error, Result};
32use crate::ohlcv::Candle;
33use crate::traits::Indicator;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Direction {
38 None,
39 Buy,
40 Sell,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct TdSequentialOutput {
47 pub setup: f64,
51 pub countdown: f64,
55 pub direction: f64,
58}
59
60#[derive(Debug, Clone)]
62pub struct TdSequential {
63 candles: VecDeque<Candle>,
67 setup_lookback: usize,
68 setup_target: usize,
69 countdown_lookback: usize,
70 countdown_target: usize,
71 buy_setup: usize,
72 sell_setup: usize,
73 buy_countdown: usize,
74 sell_countdown: usize,
75 countdown_dir: Direction,
76 ready: bool,
77}
78
79impl TdSequential {
80 pub fn new(
88 setup_lookback: usize,
89 setup_target: usize,
90 countdown_lookback: usize,
91 countdown_target: usize,
92 ) -> Result<Self> {
93 if setup_lookback == 0
94 || setup_target == 0
95 || countdown_lookback == 0
96 || countdown_target == 0
97 {
98 return Err(Error::PeriodZero);
99 }
100 let cap = setup_lookback.max(countdown_lookback) + 1;
103 Ok(Self {
104 candles: VecDeque::with_capacity(cap),
105 setup_lookback,
106 setup_target,
107 countdown_lookback,
108 countdown_target,
109 buy_setup: 0,
110 sell_setup: 0,
111 buy_countdown: 0,
112 sell_countdown: 0,
113 countdown_dir: Direction::None,
114 ready: false,
115 })
116 }
117
118 pub fn classic() -> Self {
121 Self::new(4, 9, 2, 13).expect("classic TD Sequential parameters are valid")
122 }
123
124 pub const fn params(&self) -> (usize, usize, usize, usize) {
127 (
128 self.setup_lookback,
129 self.setup_target,
130 self.countdown_lookback,
131 self.countdown_target,
132 )
133 }
134}
135
136impl Indicator for TdSequential {
137 type Input = Candle;
138 type Output = TdSequentialOutput;
139
140 fn update(&mut self, candle: Candle) -> Option<TdSequentialOutput> {
141 let cap = self.setup_lookback.max(self.countdown_lookback) + 1;
142 if self.candles.len() == cap {
143 self.candles.pop_front();
144 }
145 let need = self.setup_lookback.max(self.countdown_lookback);
149 if self.candles.len() < need {
150 self.candles.push_back(candle);
151 return None;
152 }
153
154 let setup_ref_idx = need - self.setup_lookback;
159 let setup_ref_close = self.candles[setup_ref_idx].close;
160
161 if candle.close < setup_ref_close {
162 self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
163 self.sell_setup = 0;
164 } else if candle.close > setup_ref_close {
165 self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
166 self.buy_setup = 0;
167 } else {
168 self.buy_setup = 0;
169 self.sell_setup = 0;
170 }
171
172 if self.buy_setup == self.setup_target {
176 if self.countdown_dir != Direction::Buy {
177 self.buy_countdown = 0;
178 self.sell_countdown = 0;
179 }
180 self.countdown_dir = Direction::Buy;
181 } else if self.sell_setup == self.setup_target {
182 if self.countdown_dir != Direction::Sell {
183 self.buy_countdown = 0;
184 self.sell_countdown = 0;
185 }
186 self.countdown_dir = Direction::Sell;
187 }
188
189 let cd_ref_idx = need - self.countdown_lookback;
195 let cd_ref = &self.candles[cd_ref_idx];
196 match self.countdown_dir {
197 Direction::Buy => {
198 if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target {
199 self.buy_countdown += 1;
200 }
201 }
202 Direction::Sell => {
203 if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target {
204 self.sell_countdown += 1;
205 }
206 }
207 Direction::None => {}
208 }
209
210 self.candles.push_back(candle);
211 self.ready = true;
212
213 let setup = if self.buy_setup > 0 {
214 self.buy_setup as f64
215 } else if self.sell_setup > 0 {
216 -(self.sell_setup as f64)
217 } else {
218 0.0
219 };
220 let (countdown, direction) = match self.countdown_dir {
221 Direction::Buy => (self.buy_countdown as f64, 1.0),
222 Direction::Sell => (-(self.sell_countdown as f64), -1.0),
223 Direction::None => (0.0, 0.0),
224 };
225
226 Some(TdSequentialOutput {
227 setup,
228 countdown,
229 direction,
230 })
231 }
232
233 fn reset(&mut self) {
234 self.candles.clear();
235 self.buy_setup = 0;
236 self.sell_setup = 0;
237 self.buy_countdown = 0;
238 self.sell_countdown = 0;
239 self.countdown_dir = Direction::None;
240 self.ready = false;
241 }
242
243 fn warmup_period(&self) -> usize {
244 self.setup_lookback.max(self.countdown_lookback) + 1
245 }
246
247 fn is_ready(&self) -> bool {
248 self.ready
249 }
250
251 fn name(&self) -> &'static str {
252 "TDSequential"
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::traits::BatchExt;
260
261 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
262 Candle::new_unchecked(close, high, low, close, 0.0, ts)
263 }
264
265 #[test]
266 fn pure_uptrend_completes_sell_setup_then_progresses_countdown() {
267 let candles: Vec<Candle> = (1..=40)
273 .map(|i| {
274 c(
275 f64::from(i) + 0.5,
276 f64::from(i) - 0.5,
277 f64::from(i),
278 i64::from(i),
279 )
280 })
281 .collect();
282 let mut td = TdSequential::classic();
283 let out = td.batch(&candles);
284
285 for v in out.iter().take(4) {
287 assert!(v.is_none());
288 }
289 let at_12 = out[12].expect("setup ready");
292 assert_eq!(at_12.setup, -9.0);
293 assert_eq!(at_12.direction, -1.0); let later = out[30].expect("ready");
298 assert_eq!(later.direction, -1.0);
299 assert_eq!(later.countdown, -13.0);
300 }
301
302 #[test]
303 fn pure_downtrend_completes_buy_setup_then_progresses_countdown() {
304 let candles: Vec<Candle> = (1..=40)
309 .rev()
310 .enumerate()
311 .map(|(k, i)| {
312 c(
313 f64::from(i) + 0.5,
314 f64::from(i) - 0.5,
315 f64::from(i),
316 i64::try_from(k).unwrap(),
317 )
318 })
319 .collect();
320 let mut td = TdSequential::classic();
321 let out = td.batch(&candles);
322
323 for v in out.iter().take(4) {
325 assert!(v.is_none());
326 }
327 let at_12 = out[12].expect("setup ready");
328 assert_eq!(at_12.setup, 9.0);
329 assert_eq!(at_12.direction, 1.0); let later = out[30].expect("ready");
333 assert_eq!(later.direction, 1.0);
334 assert_eq!(later.countdown, 13.0);
335 }
336
337 #[test]
338 fn flat_series_emits_zero_setup_and_no_countdown() {
339 let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
342 let mut td = TdSequential::classic();
343 let out = td.batch(&candles);
344 for v in out.iter().skip(5) {
345 let o = v.expect("ready post-warmup");
346 assert_eq!(o.setup, 0.0);
347 assert_eq!(o.countdown, 0.0);
348 assert_eq!(o.direction, 0.0);
349 }
350 }
351
352 #[test]
353 fn batch_equals_streaming() {
354 let candles: Vec<Candle> = (0..60)
355 .map(|i| {
356 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
357 c(m + 1.0, m - 1.0, m, i64::from(i))
358 })
359 .collect();
360 let mut a = TdSequential::classic();
361 let mut b = TdSequential::classic();
362 assert_eq!(
363 a.batch(&candles),
364 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
365 );
366 }
367
368 #[test]
369 fn rejects_invalid_params() {
370 assert!(matches!(
371 TdSequential::new(0, 9, 2, 13),
372 Err(Error::PeriodZero)
373 ));
374 assert!(matches!(
375 TdSequential::new(4, 0, 2, 13),
376 Err(Error::PeriodZero)
377 ));
378 assert!(matches!(
379 TdSequential::new(4, 9, 0, 13),
380 Err(Error::PeriodZero)
381 ));
382 assert!(matches!(
383 TdSequential::new(4, 9, 2, 0),
384 Err(Error::PeriodZero)
385 ));
386 }
387
388 #[test]
389 fn reset_clears_state() {
390 let candles: Vec<Candle> = (1..=20)
391 .map(|i| {
392 c(
393 f64::from(i) + 0.5,
394 f64::from(i) - 0.5,
395 f64::from(i),
396 i64::from(i),
397 )
398 })
399 .collect();
400 let mut td = TdSequential::classic();
401 td.batch(&candles);
402 assert!(td.is_ready());
403 td.reset();
404 assert!(!td.is_ready());
405 assert_eq!(td.update(candles[0]), None);
406 }
407
408 #[test]
409 fn accessors_and_metadata() {
410 let td = TdSequential::classic();
411 assert_eq!(td.params(), (4, 9, 2, 13));
412 assert_eq!(td.warmup_period(), 5);
413 assert_eq!(td.name(), "TDSequential");
414 }
415}