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