wickra_core/indicators/
td_setup.rs1#![allow(clippy::doc_markdown)]
2
3use std::collections::VecDeque;
29
30use crate::error::{Error, Result};
31use crate::ohlcv::Candle;
32use crate::traits::Indicator;
33
34#[derive(Debug, Clone)]
52pub struct TdSetup {
53 lookback: usize,
54 target: usize,
55 closes: VecDeque<f64>,
56 buy_count: usize,
57 sell_count: usize,
58 last_value: Option<f64>,
59}
60
61impl TdSetup {
62 pub fn new(lookback: usize, target: usize) -> Result<Self> {
70 if lookback == 0 || target == 0 {
71 return Err(Error::PeriodZero);
72 }
73 Ok(Self {
74 lookback,
75 target,
76 closes: VecDeque::with_capacity(lookback + 1),
77 buy_count: 0,
78 sell_count: 0,
79 last_value: None,
80 })
81 }
82
83 pub fn classic() -> Self {
85 Self::new(4, 9).expect("classic TD Setup parameters are valid")
86 }
87
88 pub const fn params(&self) -> (usize, usize) {
90 (self.lookback, self.target)
91 }
92
93 pub const fn value(&self) -> Option<f64> {
95 self.last_value
96 }
97}
98
99impl Indicator for TdSetup {
100 type Input = Candle;
101 type Output = f64;
102
103 fn update(&mut self, candle: Candle) -> Option<f64> {
104 if self.closes.len() > self.lookback {
107 self.closes.pop_front();
108 }
109 if self.closes.len() < self.lookback {
110 self.closes.push_back(candle.close);
111 return None;
112 }
113 let reference = *self.closes.front().expect("non-empty after the guard");
116 self.closes.push_back(candle.close);
117
118 if candle.close < reference {
119 self.buy_count = (self.buy_count + 1).min(self.target);
120 self.sell_count = 0;
121 let v = self.buy_count as f64;
122 self.last_value = Some(v);
123 Some(v)
124 } else if candle.close > reference {
125 self.sell_count = (self.sell_count + 1).min(self.target);
126 self.buy_count = 0;
127 let v = -(self.sell_count as f64);
128 self.last_value = Some(v);
129 Some(v)
130 } else {
131 self.buy_count = 0;
133 self.sell_count = 0;
134 self.last_value = Some(0.0);
135 Some(0.0)
136 }
137 }
138
139 fn reset(&mut self) {
140 self.closes.clear();
141 self.buy_count = 0;
142 self.sell_count = 0;
143 self.last_value = None;
144 }
145
146 fn warmup_period(&self) -> usize {
147 self.lookback + 1
148 }
149
150 fn is_ready(&self) -> bool {
151 self.last_value.is_some()
152 }
153
154 fn name(&self) -> &'static str {
155 "TDSetup"
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::traits::BatchExt;
163
164 fn c(close: f64, ts: i64) -> Candle {
165 Candle::new_unchecked(close, close, close, close, 0.0, ts)
166 }
167
168 #[test]
169 fn pure_uptrend_reaches_sell_setup_9() {
170 let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
173 let mut setup = TdSetup::classic();
174 let out = setup.batch(&candles);
175 for (i, v) in out.iter().enumerate().take(4) {
179 assert!(v.is_none(), "index {i} must be None during warmup");
180 }
181 assert_eq!(out[4], Some(-1.0));
182 assert_eq!(out[5], Some(-2.0));
183 assert_eq!(out[12], Some(-9.0));
184 assert_eq!(out[13], Some(-9.0));
185 assert_eq!(out[19], Some(-9.0));
186 }
187
188 #[test]
189 fn pure_downtrend_reaches_buy_setup_9() {
190 let candles: Vec<Candle> = (1..=20)
191 .rev()
192 .enumerate()
193 .map(|(i, v)| c(f64::from(v), i64::try_from(i).unwrap()))
194 .collect();
195 let mut setup = TdSetup::classic();
196 let out = setup.batch(&candles);
197 assert_eq!(out[4], Some(1.0));
199 assert_eq!(out[12], Some(9.0));
200 assert_eq!(out[19], Some(9.0));
201 }
202
203 #[test]
204 fn flat_series_emits_zero_after_warmup() {
205 let candles: Vec<Candle> = (0..20).map(|i| c(42.0, i)).collect();
208 let mut setup = TdSetup::classic();
209 let out = setup.batch(&candles);
210 for v in out.iter().skip(4) {
211 assert_eq!(*v, Some(0.0));
212 }
213 }
214
215 #[test]
216 fn streak_resets_on_direction_flip() {
217 let candles = [
221 c(10.0, 0),
222 c(10.0, 1),
223 c(10.0, 2),
224 c(10.0, 3),
225 c(9.0, 4),
226 c(8.0, 5),
227 c(7.0, 6),
228 c(6.0, 7),
229 c(11.0, 8),
230 ];
231 let mut setup = TdSetup::classic();
232 let out = setup.batch(&candles);
233 assert_eq!(out[4], Some(1.0));
234 assert_eq!(out[7], Some(4.0));
235 assert_eq!(out[8], Some(-1.0));
236 }
237
238 #[test]
239 fn rejects_zero_arguments() {
240 assert!(matches!(TdSetup::new(0, 9), Err(Error::PeriodZero)));
241 assert!(matches!(TdSetup::new(4, 0), Err(Error::PeriodZero)));
242 }
243
244 #[test]
245 fn batch_equals_streaming() {
246 let candles: Vec<Candle> = (0..80)
247 .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0, i64::from(i)))
248 .collect();
249 let mut a = TdSetup::classic();
250 let mut b = TdSetup::classic();
251 assert_eq!(
252 a.batch(&candles),
253 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
254 );
255 }
256
257 #[test]
258 fn reset_clears_state() {
259 let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
260 let mut setup = TdSetup::classic();
261 setup.batch(&candles);
262 assert!(setup.is_ready());
263 setup.reset();
264 assert!(!setup.is_ready());
265 assert_eq!(setup.update(candles[0]), None);
266 assert_eq!(setup.value(), None);
267 }
268
269 #[test]
270 fn accessors_and_metadata() {
271 let setup = TdSetup::new(4, 9).unwrap();
272 assert_eq!(setup.params(), (4, 9));
273 assert_eq!(setup.warmup_period(), 5);
274 assert_eq!(setup.name(), "TDSetup");
275 assert_eq!(setup.value(), None);
276 }
277}