1#![allow(clippy::doc_markdown)]
2
3use std::collections::VecDeque;
27
28use crate::error::{Error, Result};
29use crate::ohlcv::Candle;
30use crate::traits::Indicator;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum Direction {
35 None,
36 Buy,
37 Sell,
38}
39
40#[derive(Debug, Clone)]
57pub struct TdCombo {
58 setup_lookback: usize,
59 setup_target: usize,
60 countdown_lookback: usize,
61 countdown_target: usize,
62 candles: VecDeque<Candle>,
63 buy_setup: usize,
64 sell_setup: usize,
65 buy_combo: usize,
66 sell_combo: usize,
67 direction: Direction,
68 ready: bool,
69}
70
71impl TdCombo {
72 pub fn new(
80 setup_lookback: usize,
81 setup_target: usize,
82 countdown_lookback: usize,
83 countdown_target: usize,
84 ) -> Result<Self> {
85 if setup_lookback == 0
86 || setup_target == 0
87 || countdown_lookback == 0
88 || countdown_target == 0
89 {
90 return Err(Error::PeriodZero);
91 }
92 let cap = setup_lookback.max(countdown_lookback) + 1;
93 Ok(Self {
94 setup_lookback,
95 setup_target,
96 countdown_lookback,
97 countdown_target,
98 candles: VecDeque::with_capacity(cap),
99 buy_setup: 0,
100 sell_setup: 0,
101 buy_combo: 0,
102 sell_combo: 0,
103 direction: Direction::None,
104 ready: false,
105 })
106 }
107
108 pub fn classic() -> Self {
111 Self::new(4, 9, 2, 13).expect("classic TD Combo parameters are valid")
112 }
113
114 pub const fn params(&self) -> (usize, usize, usize, usize) {
117 (
118 self.setup_lookback,
119 self.setup_target,
120 self.countdown_lookback,
121 self.countdown_target,
122 )
123 }
124}
125
126impl Indicator for TdCombo {
127 type Input = Candle;
128 type Output = f64;
129
130 fn update(&mut self, candle: Candle) -> Option<f64> {
131 let need = self.setup_lookback.max(self.countdown_lookback);
132 let cap = need + 1;
133 if self.candles.len() == cap {
134 self.candles.pop_front();
135 }
136 if self.candles.len() < need {
137 self.candles.push_back(candle);
138 return None;
139 }
140
141 let setup_ref_idx = need - self.setup_lookback;
143 let setup_ref_close = self.candles[setup_ref_idx].close;
144 if candle.close < setup_ref_close {
145 self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
146 self.sell_setup = 0;
147 } else if candle.close > setup_ref_close {
148 self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
149 self.buy_setup = 0;
150 } else {
151 self.buy_setup = 0;
152 self.sell_setup = 0;
153 }
154
155 if self.buy_setup == self.setup_target {
159 if self.direction != Direction::Buy {
160 self.buy_combo = 0;
161 self.sell_combo = 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_combo = 0;
167 self.sell_combo = 0;
168 }
169 self.direction = Direction::Sell;
170 }
171
172 let combo_ref = self.candles[need - self.countdown_lookback];
176 let prev = self.candles[need - 1];
177 match self.direction {
178 Direction::Buy => {
179 let cond_classic = candle.close <= combo_ref.low;
180 let cond_low = candle.low <= prev.low;
181 let cond_close = candle.close < prev.close;
182 if cond_classic && cond_low && cond_close && self.buy_combo < self.countdown_target
183 {
184 self.buy_combo += 1;
185 }
186 }
187 Direction::Sell => {
188 let cond_classic = candle.close >= combo_ref.high;
189 let cond_high = candle.high >= prev.high;
190 let cond_close = candle.close > prev.close;
191 if cond_classic
192 && cond_high
193 && cond_close
194 && self.sell_combo < self.countdown_target
195 {
196 self.sell_combo += 1;
197 }
198 }
199 Direction::None => {}
200 }
201
202 self.candles.push_back(candle);
203 self.ready = true;
204
205 let v = match self.direction {
206 Direction::Buy => self.buy_combo as f64,
207 Direction::Sell => -(self.sell_combo as f64),
208 Direction::None => 0.0,
209 };
210 Some(v)
211 }
212
213 fn reset(&mut self) {
214 self.candles.clear();
215 self.buy_setup = 0;
216 self.sell_setup = 0;
217 self.buy_combo = 0;
218 self.sell_combo = 0;
219 self.direction = Direction::None;
220 self.ready = false;
221 }
222
223 #[inline]
224 fn warmup_period(&self) -> usize {
225 self.setup_lookback.max(self.countdown_lookback) + 1
226 }
227
228 #[inline]
229 fn is_ready(&self) -> bool {
230 self.ready
231 }
232
233 #[inline]
234 fn name(&self) -> &'static str {
235 "TDCombo"
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::traits::BatchExt;
243
244 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
245 Candle::new_unchecked(close, high, low, close, 0.0, ts)
246 }
247
248 #[test]
249 fn pure_uptrend_arms_sell_combo_and_advances() {
250 let candles: Vec<Candle> = (1..=40)
255 .map(|i| {
256 c(
257 f64::from(i) + 0.5,
258 f64::from(i) - 0.5,
259 f64::from(i),
260 i64::from(i),
261 )
262 })
263 .collect();
264 let mut combo = TdCombo::classic();
265 let out = combo.batch(&candles);
266 for v in out.iter().take(4) {
268 assert!(v.is_none());
269 }
270 let at_12 = out[12].expect("ready");
275 assert_eq!(at_12, -1.0);
276 let later = out[30].expect("ready");
278 assert_eq!(later, -13.0);
279 }
280
281 #[test]
282 fn pure_downtrend_arms_buy_combo_and_advances() {
283 let candles: Vec<Candle> = (1..=40)
288 .rev()
289 .enumerate()
290 .map(|(k, i)| {
291 c(
292 f64::from(i) + 0.5,
293 f64::from(i) - 0.5,
294 f64::from(i),
295 i64::try_from(k).unwrap(),
296 )
297 })
298 .collect();
299 let mut combo = TdCombo::classic();
300 let out = combo.batch(&candles);
301 for v in out.iter().take(4) {
302 assert!(v.is_none());
303 }
304 let at_12 = out[12].expect("ready");
309 assert_eq!(at_12, 1.0);
310 let later = out[30].expect("ready");
312 assert_eq!(later, 13.0);
313 }
314
315 #[test]
316 fn flat_series_never_arms_combo() {
317 let candles: Vec<Candle> = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
319 let mut combo = TdCombo::classic();
320 for v in combo.batch(&candles).into_iter().flatten() {
321 assert_eq!(v, 0.0);
322 }
323 }
324
325 #[test]
326 fn batch_equals_streaming() {
327 let candles: Vec<Candle> = (0..80)
328 .map(|i| {
329 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
330 c(m + 1.0, m - 1.0, m, i64::from(i))
331 })
332 .collect();
333 let mut a = TdCombo::classic();
334 let mut b = TdCombo::classic();
335 assert_eq!(
336 a.batch(&candles),
337 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
338 );
339 }
340
341 #[test]
342 fn rejects_invalid_params() {
343 assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero)));
344 assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero)));
345 assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero)));
346 assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero)));
347 }
348
349 #[test]
350 fn reset_clears_state() {
351 let candles: Vec<Candle> = (1..=30)
352 .map(|i| {
353 c(
354 f64::from(i) + 0.5,
355 f64::from(i) - 0.5,
356 f64::from(i),
357 i64::from(i),
358 )
359 })
360 .collect();
361 let mut combo = TdCombo::classic();
362 combo.batch(&candles);
363 assert!(combo.is_ready());
364 combo.reset();
365 assert!(!combo.is_ready());
366 assert_eq!(combo.update(candles[0]), None);
367 }
368
369 #[test]
370 fn accessors_and_metadata() {
371 let combo = TdCombo::classic();
372 assert_eq!(combo.params(), (4, 9, 2, 13));
373 assert_eq!(combo.warmup_period(), 5);
374 assert_eq!(combo.name(), "TDCombo");
375 }
376}