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)]
60pub struct TdCountdown {
61 setup_lookback: usize,
62 setup_target: usize,
63 countdown_lookback: usize,
64 countdown_target: usize,
65 candles: VecDeque<Candle>,
66 buy_setup: usize,
67 sell_setup: usize,
68 buy_countdown: usize,
69 sell_countdown: usize,
70 direction: Direction,
71 ready: bool,
72}
73
74impl TdCountdown {
75 pub fn new(
83 setup_lookback: usize,
84 setup_target: usize,
85 countdown_lookback: usize,
86 countdown_target: usize,
87 ) -> Result<Self> {
88 if setup_lookback == 0
89 || setup_target == 0
90 || countdown_lookback == 0
91 || countdown_target == 0
92 {
93 return Err(Error::PeriodZero);
94 }
95 let cap = setup_lookback.max(countdown_lookback) + 1;
96 Ok(Self {
97 setup_lookback,
98 setup_target,
99 countdown_lookback,
100 countdown_target,
101 candles: VecDeque::with_capacity(cap),
102 buy_setup: 0,
103 sell_setup: 0,
104 buy_countdown: 0,
105 sell_countdown: 0,
106 direction: Direction::None,
107 ready: false,
108 })
109 }
110
111 pub fn classic() -> Self {
114 Self::new(4, 9, 2, 13).expect("classic TD Countdown parameters are valid")
115 }
116
117 pub const fn params(&self) -> (usize, usize, usize, usize) {
120 (
121 self.setup_lookback,
122 self.setup_target,
123 self.countdown_lookback,
124 self.countdown_target,
125 )
126 }
127}
128
129impl Indicator for TdCountdown {
130 type Input = Candle;
131 type Output = f64;
132
133 fn update(&mut self, candle: Candle) -> Option<f64> {
134 let need = self.setup_lookback.max(self.countdown_lookback);
135 let cap = need + 1;
136 if self.candles.len() == cap {
137 self.candles.pop_front();
138 }
139 if self.candles.len() < need {
140 self.candles.push_back(candle);
141 return None;
142 }
143
144 let setup_ref_idx = need - self.setup_lookback;
146 let setup_ref_close = self.candles[setup_ref_idx].close;
147 if candle.close < setup_ref_close {
148 self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
149 self.sell_setup = 0;
150 } else if candle.close > setup_ref_close {
151 self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
152 self.buy_setup = 0;
153 } else {
154 self.buy_setup = 0;
155 self.sell_setup = 0;
156 }
157
158 if self.buy_setup == self.setup_target {
159 if self.direction != Direction::Buy {
160 self.buy_countdown = 0;
161 self.sell_countdown = 0;
162 }
163 self.direction = Direction::Buy;
164 } else if self.sell_setup == self.setup_target {
165 if self.direction != Direction::Sell {
166 self.buy_countdown = 0;
167 self.sell_countdown = 0;
168 }
169 self.direction = Direction::Sell;
170 }
171
172 let cd_ref = self.candles[need - self.countdown_lookback];
173 match self.direction {
174 Direction::Buy => {
175 if candle.close <= cd_ref.low && self.buy_countdown < self.countdown_target {
176 self.buy_countdown += 1;
177 }
178 }
179 Direction::Sell => {
180 if candle.close >= cd_ref.high && self.sell_countdown < self.countdown_target {
181 self.sell_countdown += 1;
182 }
183 }
184 Direction::None => {}
185 }
186
187 self.candles.push_back(candle);
188 self.ready = true;
189
190 let v = match self.direction {
191 Direction::Buy => self.buy_countdown as f64,
192 Direction::Sell => -(self.sell_countdown as f64),
193 Direction::None => 0.0,
194 };
195 Some(v)
196 }
197
198 fn reset(&mut self) {
199 self.candles.clear();
200 self.buy_setup = 0;
201 self.sell_setup = 0;
202 self.buy_countdown = 0;
203 self.sell_countdown = 0;
204 self.direction = Direction::None;
205 self.ready = false;
206 }
207
208 #[inline]
209 fn warmup_period(&self) -> usize {
210 self.setup_lookback.max(self.countdown_lookback) + 1
211 }
212
213 #[inline]
214 fn is_ready(&self) -> bool {
215 self.ready
216 }
217
218 #[inline]
219 fn name(&self) -> &'static str {
220 "TDCountdown"
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::traits::BatchExt;
228
229 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
230 Candle::new_unchecked(close, high, low, close, 0.0, ts)
231 }
232
233 #[test]
234 fn pure_uptrend_completes_setup_then_runs_sell_countdown_to_minus_13() {
235 let candles: Vec<Candle> = (1..=40)
236 .map(|i| {
237 c(
238 f64::from(i) + 0.5,
239 f64::from(i) - 0.5,
240 f64::from(i),
241 i64::from(i),
242 )
243 })
244 .collect();
245 let mut td = TdCountdown::classic();
246 let out = td.batch(&candles);
247 for v in out.iter().take(4) {
249 assert!(v.is_none());
250 }
251 assert_eq!(out[12].expect("ready"), -1.0);
255 assert_eq!(out[30].expect("ready"), -13.0);
257 }
258
259 #[test]
260 fn pure_downtrend_completes_setup_then_runs_buy_countdown_to_plus_13() {
261 let candles: Vec<Candle> = (1..=40)
262 .rev()
263 .enumerate()
264 .map(|(k, i)| {
265 c(
266 f64::from(i) + 0.5,
267 f64::from(i) - 0.5,
268 f64::from(i),
269 i64::try_from(k).unwrap(),
270 )
271 })
272 .collect();
273 let mut td = TdCountdown::classic();
274 let out = td.batch(&candles);
275 for v in out.iter().take(4) {
276 assert!(v.is_none());
277 }
278 assert_eq!(out[12].expect("ready"), 1.0);
282 assert_eq!(out[30].expect("ready"), 13.0);
284 }
285
286 #[test]
287 fn flat_series_never_arms_countdown() {
288 let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
289 let mut td = TdCountdown::classic();
290 for v in td.batch(&candles).into_iter().flatten() {
291 assert_eq!(v, 0.0);
292 }
293 }
294
295 #[test]
296 fn batch_equals_streaming() {
297 let candles: Vec<Candle> = (0..80)
298 .map(|i| {
299 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
300 c(m + 1.0, m - 1.0, m, i64::from(i))
301 })
302 .collect();
303 let mut a = TdCountdown::classic();
304 let mut b = TdCountdown::classic();
305 assert_eq!(
306 a.batch(&candles),
307 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
308 );
309 }
310
311 #[test]
312 fn rejects_invalid_params() {
313 assert!(matches!(
314 TdCountdown::new(0, 9, 2, 13),
315 Err(Error::PeriodZero)
316 ));
317 assert!(matches!(
318 TdCountdown::new(4, 0, 2, 13),
319 Err(Error::PeriodZero)
320 ));
321 assert!(matches!(
322 TdCountdown::new(4, 9, 0, 13),
323 Err(Error::PeriodZero)
324 ));
325 assert!(matches!(
326 TdCountdown::new(4, 9, 2, 0),
327 Err(Error::PeriodZero)
328 ));
329 }
330
331 #[test]
332 fn reset_clears_state() {
333 let candles: Vec<Candle> = (1..=30)
334 .map(|i| {
335 c(
336 f64::from(i) + 0.5,
337 f64::from(i) - 0.5,
338 f64::from(i),
339 i64::from(i),
340 )
341 })
342 .collect();
343 let mut td = TdCountdown::classic();
344 td.batch(&candles);
345 assert!(td.is_ready());
346 td.reset();
347 assert!(!td.is_ready());
348 assert_eq!(td.update(candles[0]), None);
349 }
350
351 #[test]
352 fn accessors_and_metadata() {
353 let td = TdCountdown::classic();
354 assert_eq!(td.params(), (4, 9, 2, 13));
355 assert_eq!(td.warmup_period(), 5);
356 assert_eq!(td.name(), "TDCountdown");
357 }
358}