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)]
42pub struct TdCombo {
43 setup_lookback: usize,
44 setup_target: usize,
45 countdown_lookback: usize,
46 countdown_target: usize,
47 candles: VecDeque<Candle>,
48 buy_setup: usize,
49 sell_setup: usize,
50 buy_combo: usize,
51 sell_combo: usize,
52 direction: Direction,
53 ready: bool,
54}
55
56impl TdCombo {
57 pub fn new(
65 setup_lookback: usize,
66 setup_target: usize,
67 countdown_lookback: usize,
68 countdown_target: usize,
69 ) -> Result<Self> {
70 if setup_lookback == 0
71 || setup_target == 0
72 || countdown_lookback == 0
73 || countdown_target == 0
74 {
75 return Err(Error::PeriodZero);
76 }
77 let cap = setup_lookback.max(countdown_lookback) + 1;
78 Ok(Self {
79 setup_lookback,
80 setup_target,
81 countdown_lookback,
82 countdown_target,
83 candles: VecDeque::with_capacity(cap),
84 buy_setup: 0,
85 sell_setup: 0,
86 buy_combo: 0,
87 sell_combo: 0,
88 direction: Direction::None,
89 ready: false,
90 })
91 }
92
93 pub fn classic() -> Self {
96 Self::new(4, 9, 2, 13).expect("classic TD Combo parameters are valid")
97 }
98
99 pub const fn params(&self) -> (usize, usize, usize, usize) {
102 (
103 self.setup_lookback,
104 self.setup_target,
105 self.countdown_lookback,
106 self.countdown_target,
107 )
108 }
109}
110
111impl Indicator for TdCombo {
112 type Input = Candle;
113 type Output = f64;
114
115 fn update(&mut self, candle: Candle) -> Option<f64> {
116 let need = self.setup_lookback.max(self.countdown_lookback);
117 let cap = need + 1;
118 if self.candles.len() == cap {
119 self.candles.pop_front();
120 }
121 if self.candles.len() < need {
122 self.candles.push_back(candle);
123 return None;
124 }
125
126 let setup_ref_idx = need - self.setup_lookback;
128 let setup_ref_close = self.candles[setup_ref_idx].close;
129 if candle.close < setup_ref_close {
130 self.buy_setup = (self.buy_setup + 1).min(self.setup_target);
131 self.sell_setup = 0;
132 } else if candle.close > setup_ref_close {
133 self.sell_setup = (self.sell_setup + 1).min(self.setup_target);
134 self.buy_setup = 0;
135 } else {
136 self.buy_setup = 0;
137 self.sell_setup = 0;
138 }
139
140 if self.buy_setup == self.setup_target {
144 if self.direction != Direction::Buy {
145 self.buy_combo = 0;
146 self.sell_combo = 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_combo = 0;
152 self.sell_combo = 0;
153 }
154 self.direction = Direction::Sell;
155 }
156
157 let combo_ref = self.candles[need - self.countdown_lookback];
161 let prev = self.candles[need - 1];
162 match self.direction {
163 Direction::Buy => {
164 let cond_classic = candle.close <= combo_ref.low;
165 let cond_low = candle.low <= prev.low;
166 let cond_close = candle.close < prev.close;
167 if cond_classic && cond_low && cond_close && self.buy_combo < self.countdown_target
168 {
169 self.buy_combo += 1;
170 }
171 }
172 Direction::Sell => {
173 let cond_classic = candle.close >= combo_ref.high;
174 let cond_high = candle.high >= prev.high;
175 let cond_close = candle.close > prev.close;
176 if cond_classic
177 && cond_high
178 && cond_close
179 && self.sell_combo < self.countdown_target
180 {
181 self.sell_combo += 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_combo as f64,
192 Direction::Sell => -(self.sell_combo 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_combo = 0;
203 self.sell_combo = 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 "TDCombo"
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_arms_sell_combo_and_advances() {
232 let candles: Vec<Candle> = (1..=40)
237 .map(|i| {
238 c(
239 f64::from(i) + 0.5,
240 f64::from(i) - 0.5,
241 f64::from(i),
242 i64::from(i),
243 )
244 })
245 .collect();
246 let mut combo = TdCombo::classic();
247 let out = combo.batch(&candles);
248 for v in out.iter().take(4) {
250 assert!(v.is_none());
251 }
252 let at_12 = out[12].expect("ready");
257 assert_eq!(at_12, -1.0);
258 let later = out[30].expect("ready");
260 assert_eq!(later, -13.0);
261 }
262
263 #[test]
264 fn pure_downtrend_arms_buy_combo_and_advances() {
265 let candles: Vec<Candle> = (1..=40)
270 .rev()
271 .enumerate()
272 .map(|(k, i)| {
273 c(
274 f64::from(i) + 0.5,
275 f64::from(i) - 0.5,
276 f64::from(i),
277 i64::try_from(k).unwrap(),
278 )
279 })
280 .collect();
281 let mut combo = TdCombo::classic();
282 let out = combo.batch(&candles);
283 for v in out.iter().take(4) {
284 assert!(v.is_none());
285 }
286 let at_12 = out[12].expect("ready");
291 assert_eq!(at_12, 1.0);
292 let later = out[30].expect("ready");
294 assert_eq!(later, 13.0);
295 }
296
297 #[test]
298 fn flat_series_never_arms_combo() {
299 let candles: Vec<Candle> = (0..40).map(|i| c(10.5, 9.5, 10.0, i64::from(i))).collect();
301 let mut combo = TdCombo::classic();
302 for v in combo.batch(&candles).into_iter().flatten() {
303 assert_eq!(v, 0.0);
304 }
305 }
306
307 #[test]
308 fn batch_equals_streaming() {
309 let candles: Vec<Candle> = (0..80)
310 .map(|i| {
311 let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
312 c(m + 1.0, m - 1.0, m, i64::from(i))
313 })
314 .collect();
315 let mut a = TdCombo::classic();
316 let mut b = TdCombo::classic();
317 assert_eq!(
318 a.batch(&candles),
319 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
320 );
321 }
322
323 #[test]
324 fn rejects_invalid_params() {
325 assert!(matches!(TdCombo::new(0, 9, 2, 13), Err(Error::PeriodZero)));
326 assert!(matches!(TdCombo::new(4, 0, 2, 13), Err(Error::PeriodZero)));
327 assert!(matches!(TdCombo::new(4, 9, 0, 13), Err(Error::PeriodZero)));
328 assert!(matches!(TdCombo::new(4, 9, 2, 0), Err(Error::PeriodZero)));
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 combo = TdCombo::classic();
344 combo.batch(&candles);
345 assert!(combo.is_ready());
346 combo.reset();
347 assert!(!combo.is_ready());
348 assert_eq!(combo.update(candles[0]), None);
349 }
350
351 #[test]
352 fn accessors_and_metadata() {
353 let combo = TdCombo::classic();
354 assert_eq!(combo.params(), (4, 9, 2, 13));
355 assert_eq!(combo.warmup_period(), 5);
356 assert_eq!(combo.name(), "TDCombo");
357 }
358}