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