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 fn warmup_period(&self) -> usize {
224 self.setup_lookback.max(self.countdown_lookback) + 1
225 }
226
227 fn is_ready(&self) -> bool {
228 self.ready
229 }
230
231 fn name(&self) -> &'static str {
232 "TDCombo"
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use crate::traits::BatchExt;
240
241 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
242 Candle::new_unchecked(close, high, low, close, 0.0, ts)
243 }
244
245 #[test]
246 fn pure_uptrend_arms_sell_combo_and_advances() {
247 let candles: Vec<Candle> = (1..=40)
252 .map(|i| {
253 c(
254 f64::from(i) + 0.5,
255 f64::from(i) - 0.5,
256 f64::from(i),
257 i64::from(i),
258 )
259 })
260 .collect();
261 let mut combo = TdCombo::classic();
262 let out = combo.batch(&candles);
263 for v in out.iter().take(4) {
265 assert!(v.is_none());
266 }
267 let at_12 = out[12].expect("ready");
272 assert_eq!(at_12, -1.0);
273 let later = out[30].expect("ready");
275 assert_eq!(later, -13.0);
276 }
277
278 #[test]
279 fn pure_downtrend_arms_buy_combo_and_advances() {
280 let candles: Vec<Candle> = (1..=40)
285 .rev()
286 .enumerate()
287 .map(|(k, i)| {
288 c(
289 f64::from(i) + 0.5,
290 f64::from(i) - 0.5,
291 f64::from(i),
292 i64::try_from(k).unwrap(),
293 )
294 })
295 .collect();
296 let mut combo = TdCombo::classic();
297 let out = combo.batch(&candles);
298 for v in out.iter().take(4) {
299 assert!(v.is_none());
300 }
301 let at_12 = out[12].expect("ready");
306 assert_eq!(at_12, 1.0);
307 let later = out[30].expect("ready");
309 assert_eq!(later, 13.0);
310 }
311
312 #[test]
313 fn flat_series_never_arms_combo() {
314 let candles: Vec<Candle> = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
316 let mut combo = TdCombo::classic();
317 for v in combo.batch(&candles).into_iter().flatten() {
318 assert_eq!(v, 0.0);
319 }
320 }
321
322 #[test]
323 fn batch_equals_streaming() {
324 let candles: Vec<Candle> = (0..80)
325 .map(|i| {
326 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
327 c(m + 1.0, m - 1.0, m, i64::from(i))
328 })
329 .collect();
330 let mut a = TdCombo::classic();
331 let mut b = TdCombo::classic();
332 assert_eq!(
333 a.batch(&candles),
334 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
335 );
336 }
337
338 #[test]
339 fn rejects_invalid_params() {
340 assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero)));
341 assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero)));
342 assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero)));
343 assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero)));
344 }
345
346 #[test]
347 fn reset_clears_state() {
348 let candles: Vec<Candle> = (1..=30)
349 .map(|i| {
350 c(
351 f64::from(i) + 0.5,
352 f64::from(i) - 0.5,
353 f64::from(i),
354 i64::from(i),
355 )
356 })
357 .collect();
358 let mut combo = TdCombo::classic();
359 combo.batch(&candles);
360 assert!(combo.is_ready());
361 combo.reset();
362 assert!(!combo.is_ready());
363 assert_eq!(combo.update(candles[0]), None);
364 }
365
366 #[test]
367 fn accessors_and_metadata() {
368 let combo = TdCombo::classic();
369 assert_eq!(combo.params(), (4, 9, 2, 13));
370 assert_eq!(combo.warmup_period(), 5);
371 assert_eq!(combo.name(), "TDCombo");
372 }
373}