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 #[inline]
104 fn update(&mut self, candle: Candle) -> Option<f64> {
105 if self.closes.len() > self.lookback {
108 self.closes.pop_front();
109 }
110 if self.closes.len() < self.lookback {
111 self.closes.push_back(candle.close);
112 return None;
113 }
114 let reference = *self.closes.front().expect("non-empty after the guard");
117 self.closes.push_back(candle.close);
118
119 if candle.close < reference {
120 self.buy_count = (self.buy_count + 1).min(self.target);
121 self.sell_count = 0;
122 let v = self.buy_count as f64;
123 self.last_value = Some(v);
124 Some(v)
125 } else if candle.close > reference {
126 self.sell_count = (self.sell_count + 1).min(self.target);
127 self.buy_count = 0;
128 let v = -(self.sell_count as f64);
129 self.last_value = Some(v);
130 Some(v)
131 } else {
132 self.buy_count = 0;
134 self.sell_count = 0;
135 self.last_value = Some(0.0);
136 Some(0.0)
137 }
138 }
139
140 fn reset(&mut self) {
141 self.closes.clear();
142 self.buy_count = 0;
143 self.sell_count = 0;
144 self.last_value = None;
145 }
146
147 #[inline]
148 fn warmup_period(&self) -> usize {
149 self.lookback + 1
150 }
151
152 #[inline]
153 fn is_ready(&self) -> bool {
154 self.last_value.is_some()
155 }
156
157 #[inline]
158 fn name(&self) -> &'static str {
159 "TDSetup"
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::traits::BatchExt;
167
168 fn c(close: f64, ts: i64) -> Candle {
169 Candle::new_unchecked(close, close, close, close, 0.0, ts)
170 }
171
172 #[test]
173 fn pure_uptrend_reaches_sell_setup_9() {
174 let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
177 let mut setup = TdSetup::classic();
178 let out = setup.batch(&candles);
179 for (i, v) in out.iter().enumerate().take(4) {
183 assert!(v.is_none(), "index {i} must be None during warmup");
184 }
185 assert_eq!(out[4], Some(-1.0));
186 assert_eq!(out[5], Some(-2.0));
187 assert_eq!(out[12], Some(-9.0));
188 assert_eq!(out[13], Some(-9.0));
189 assert_eq!(out[19], Some(-9.0));
190 }
191
192 #[test]
193 fn pure_downtrend_reaches_buy_setup_9() {
194 let candles: Vec<Candle> = (1..=20)
195 .rev()
196 .enumerate()
197 .map(|(i, v)| c(f64::from(v), i64::try_from(i).unwrap()))
198 .collect();
199 let mut setup = TdSetup::classic();
200 let out = setup.batch(&candles);
201 assert_eq!(out[4], Some(1.0));
203 assert_eq!(out[12], Some(9.0));
204 assert_eq!(out[19], Some(9.0));
205 }
206
207 #[test]
208 fn flat_series_emits_zero_after_warmup() {
209 let candles: Vec<Candle> = (0..20).map(|i| c(42.0, i)).collect();
212 let mut setup = TdSetup::classic();
213 let out = setup.batch(&candles);
214 for v in out.iter().skip(4) {
215 assert_eq!(*v, Some(0.0));
216 }
217 }
218
219 #[test]
220 fn streak_resets_on_direction_flip() {
221 let candles = [
225 c(10.0, 0),
226 c(10.0, 1),
227 c(10.0, 2),
228 c(10.0, 3),
229 c(9.0, 4),
230 c(8.0, 5),
231 c(7.0, 6),
232 c(6.0, 7),
233 c(11.0, 8),
234 ];
235 let mut setup = TdSetup::classic();
236 let out = setup.batch(&candles);
237 assert_eq!(out[4], Some(1.0));
238 assert_eq!(out[7], Some(4.0));
239 assert_eq!(out[8], Some(-1.0));
240 }
241
242 #[test]
243 fn rejects_zero_arguments() {
244 assert!(matches!(TdSetup::new(0, 9), Err(Error::PeriodZero)));
245 assert!(matches!(TdSetup::new(4, 0), Err(Error::PeriodZero)));
246 }
247
248 #[test]
249 fn batch_equals_streaming() {
250 let candles: Vec<Candle> = (0..80)
251 .map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0, i64::from(i)))
252 .collect();
253 let mut a = TdSetup::classic();
254 let mut b = TdSetup::classic();
255 assert_eq!(
256 a.batch(&candles),
257 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
258 );
259 }
260
261 #[test]
262 fn reset_clears_state() {
263 let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), i64::from(i))).collect();
264 let mut setup = TdSetup::classic();
265 setup.batch(&candles);
266 assert!(setup.is_ready());
267 setup.reset();
268 assert!(!setup.is_ready());
269 assert_eq!(setup.update(candles[0]), None);
270 assert_eq!(setup.value(), None);
271 }
272
273 #[test]
274 fn accessors_and_metadata() {
275 let setup = TdSetup::new(4, 9).unwrap();
276 assert_eq!(setup.params(), (4, 9));
277 assert_eq!(setup.warmup_period(), 5);
278 assert_eq!(setup.name(), "TDSetup");
279 assert_eq!(setup.value(), None);
280 }
281}