wickra_core/indicators/
hilo_activator.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
46pub struct HiLoActivator {
47 period: usize,
48 highs: VecDeque<f64>,
49 lows: VecDeque<f64>,
50 sum_high: f64,
51 sum_low: f64,
52 prev_smas: Option<(f64, f64)>,
55 long: bool,
57 started: bool,
59}
60
61impl HiLoActivator {
62 pub fn new(period: usize) -> Result<Self> {
67 if period == 0 {
68 return Err(Error::PeriodZero);
69 }
70 Ok(Self {
71 period,
72 highs: VecDeque::with_capacity(period),
73 lows: VecDeque::with_capacity(period),
74 sum_high: 0.0,
75 sum_low: 0.0,
76 prev_smas: None,
77 long: true,
78 started: false,
79 })
80 }
81
82 pub fn classic() -> Self {
84 Self::new(3).expect("classic period is valid")
85 }
86
87 pub const fn period(&self) -> usize {
89 self.period
90 }
91}
92
93impl Indicator for HiLoActivator {
94 type Input = Candle;
95 type Output = f64;
96
97 fn update(&mut self, candle: Candle) -> Option<f64> {
98 if self.highs.len() == self.period {
99 self.sum_high -= self.highs.pop_front().expect("non-empty by check");
100 self.sum_low -= self.lows.pop_front().expect("non-empty by check");
101 }
102 self.highs.push_back(candle.high);
103 self.lows.push_back(candle.low);
104 self.sum_high += candle.high;
105 self.sum_low += candle.low;
106
107 if self.highs.len() < self.period {
111 return None;
112 }
113 let p = self.period as f64;
114 let hi_sma = self.sum_high / p;
115 let lo_sma = self.sum_low / p;
116
117 let out = if let Some((prev_hi, prev_lo)) = self.prev_smas {
118 if candle.close > prev_hi {
119 self.long = true;
120 } else if candle.close < prev_lo {
121 self.long = false;
122 }
123 self.started = true;
124 if self.long {
125 prev_lo
126 } else {
127 prev_hi
128 }
129 } else {
130 self.prev_smas = Some((hi_sma, lo_sma));
132 return None;
133 };
134 self.prev_smas = Some((hi_sma, lo_sma));
135 Some(out)
136 }
137
138 fn reset(&mut self) {
139 self.highs.clear();
140 self.lows.clear();
141 self.sum_high = 0.0;
142 self.sum_low = 0.0;
143 self.prev_smas = None;
144 self.long = true;
145 self.started = false;
146 }
147
148 fn warmup_period(&self) -> usize {
149 self.period + 1
150 }
151
152 fn is_ready(&self) -> bool {
153 self.started
154 }
155
156 fn name(&self) -> &'static str {
157 "HiLoActivator"
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::traits::BatchExt;
165 use approx::assert_relative_eq;
166
167 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
168 Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
169 }
170
171 #[test]
172 fn rejects_zero_period() {
173 assert!(HiLoActivator::new(0).is_err());
174 }
175
176 #[test]
177 fn accessors_and_metadata() {
178 let s = HiLoActivator::classic();
179 assert_eq!(s.period(), 3);
180 assert_eq!(s.warmup_period(), 4);
181 assert_eq!(s.name(), "HiLoActivator");
182 }
183
184 #[test]
185 fn warmup_emits_none_until_period_plus_one() {
186 let mut s = HiLoActivator::new(3).unwrap();
187 let candles: Vec<Candle> = (0..6)
189 .map(|i| {
190 let base = 100.0 + i as f64;
191 c(base + 1.0, base - 1.0, base, i)
192 })
193 .collect();
194 let out = s.batch(&candles);
195 assert!(out[0].is_none());
196 assert!(out[1].is_none());
197 assert!(out[2].is_none());
198 assert!(out[3].is_some(), "first emission lands at index period");
199 }
200
201 #[test]
202 fn constant_series_stays_long_on_lo_sma() {
203 let mut s = HiLoActivator::new(3).unwrap();
204 let candles: Vec<Candle> = (0..10).map(|i| c(11.0, 9.0, 10.0, i)).collect();
206 for v in s.batch(&candles).into_iter().flatten() {
207 assert_relative_eq!(v, 9.0, epsilon = 1e-12);
209 }
210 }
211
212 #[test]
213 fn uptrend_keeps_emitting_low_sma_below_close() {
214 let mut s = HiLoActivator::new(3).unwrap();
215 let candles: Vec<Candle> = (0..30)
216 .map(|i| {
217 let base = 100.0 + i as f64;
218 c(base + 1.0, base - 1.0, base, i)
219 })
220 .collect();
221 let paired: Vec<(f64, f64)> = s
222 .batch(&candles)
223 .into_iter()
224 .zip(candles.iter())
225 .filter_map(|(o, c)| o.map(|v| (v, c.close)))
226 .collect();
227 assert!(
228 paired.iter().all(|(stop, close)| stop < close),
229 "uptrend stop should sit below the close"
230 );
231 }
232
233 #[test]
234 fn reset_clears_state() {
235 let mut s = HiLoActivator::new(3).unwrap();
236 let candles: Vec<Candle> = (0..20)
237 .map(|i| {
238 let base = 100.0 + i as f64;
239 c(base + 1.0, base - 1.0, base, i)
240 })
241 .collect();
242 s.batch(&candles);
243 assert!(s.is_ready());
244 s.reset();
245 assert!(!s.is_ready());
246 assert_eq!(s.update(candles[0]), None);
247 }
248
249 #[test]
250 fn batch_equals_streaming() {
251 let candles: Vec<Candle> = (0..80)
252 .map(|i| {
253 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
254 c(mid + 1.5, mid - 1.5, mid + 0.5, i)
255 })
256 .collect();
257 let mut a = HiLoActivator::classic();
258 let mut b = HiLoActivator::classic();
259 assert_eq!(
260 a.batch(&candles),
261 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
262 );
263 }
264}