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 fn warmup_period(&self) -> usize {
209 self.setup_lookback.max(self.countdown_lookback) + 1
210 }
211
212 fn is_ready(&self) -> bool {
213 self.ready
214 }
215
216 fn name(&self) -> &'static str {
217 "TDCountdown"
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::traits::BatchExt;
225
226 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
227 Candle::new_unchecked(close, high, low, close, 0.0, ts)
228 }
229
230 #[test]
231 fn pure_uptrend_completes_setup_then_runs_sell_countdown_to_minus_13() {
232 let candles: Vec<Candle> = (1..=40)
233 .map(|i| {
234 c(
235 f64::from(i) + 0.5,
236 f64::from(i) - 0.5,
237 f64::from(i),
238 i64::from(i),
239 )
240 })
241 .collect();
242 let mut td = TdCountdown::classic();
243 let out = td.batch(&candles);
244 for v in out.iter().take(4) {
246 assert!(v.is_none());
247 }
248 assert_eq!(out[12].expect("ready"), -1.0);
252 assert_eq!(out[30].expect("ready"), -13.0);
254 }
255
256 #[test]
257 fn pure_downtrend_completes_setup_then_runs_buy_countdown_to_plus_13() {
258 let candles: Vec<Candle> = (1..=40)
259 .rev()
260 .enumerate()
261 .map(|(k, i)| {
262 c(
263 f64::from(i) + 0.5,
264 f64::from(i) - 0.5,
265 f64::from(i),
266 i64::try_from(k).unwrap(),
267 )
268 })
269 .collect();
270 let mut td = TdCountdown::classic();
271 let out = td.batch(&candles);
272 for v in out.iter().take(4) {
273 assert!(v.is_none());
274 }
275 assert_eq!(out[12].expect("ready"), 1.0);
279 assert_eq!(out[30].expect("ready"), 13.0);
281 }
282
283 #[test]
284 fn flat_series_never_arms_countdown() {
285 let candles: Vec<Candle> = (0..30).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
286 let mut td = TdCountdown::classic();
287 for v in td.batch(&candles).into_iter().flatten() {
288 assert_eq!(v, 0.0);
289 }
290 }
291
292 #[test]
293 fn batch_equals_streaming() {
294 let candles: Vec<Candle> = (0..80)
295 .map(|i| {
296 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
297 c(m + 1.0, m - 1.0, m, i64::from(i))
298 })
299 .collect();
300 let mut a = TdCountdown::classic();
301 let mut b = TdCountdown::classic();
302 assert_eq!(
303 a.batch(&candles),
304 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
305 );
306 }
307
308 #[test]
309 fn rejects_invalid_params() {
310 assert!(matches!(
311 TdCountdown::new(0, 9, 2, 13),
312 Err(Error::PeriodZero)
313 ));
314 assert!(matches!(
315 TdCountdown::new(4, 0, 2, 13),
316 Err(Error::PeriodZero)
317 ));
318 assert!(matches!(
319 TdCountdown::new(4, 9, 0, 13),
320 Err(Error::PeriodZero)
321 ));
322 assert!(matches!(
323 TdCountdown::new(4, 9, 2, 0),
324 Err(Error::PeriodZero)
325 ));
326 }
327
328 #[test]
329 fn reset_clears_state() {
330 let candles: Vec<Candle> = (1..=30)
331 .map(|i| {
332 c(
333 f64::from(i) + 0.5,
334 f64::from(i) - 0.5,
335 f64::from(i),
336 i64::from(i),
337 )
338 })
339 .collect();
340 let mut td = TdCountdown::classic();
341 td.batch(&candles);
342 assert!(td.is_ready());
343 td.reset();
344 assert!(!td.is_ready());
345 assert_eq!(td.update(candles[0]), None);
346 }
347
348 #[test]
349 fn accessors_and_metadata() {
350 let td = TdCountdown::classic();
351 assert_eq!(td.params(), (4, 9, 2, 13));
352 assert_eq!(td.warmup_period(), 5);
353 assert_eq!(td.name(), "TDCountdown");
354 }
355}