Skip to main content

wickra_core/indicators/
hilo_activator.rs

1//! `HiLo` Activator (Crabel).
2
3use 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/// `HiLo` Activator — Robert Krausz's adaptation of Linda Bradford Raschke and
11/// Larry Connors' "`HiLo`" rule, popularised by Toby Crabel. Two simple moving
12/// averages — of the high and of the low — bracket price; the trailing stop
13/// for a long sits at the SMA-of-low, and for a short at the SMA-of-high.
14///
15/// ```text
16/// hi_sma = SMA(high, period)        // potential short stop
17/// lo_sma = SMA(low,  period)        // potential long stop
18///
19/// state-machine:
20///   long  while close > hi_sma_prev   ->  emit lo_sma_prev
21///   short while close < lo_sma_prev   ->  emit hi_sma_prev
22///   else: hold the previous side
23/// ```
24///
25/// Comparing the close to the *previous* bar's SMA avoids look-ahead and gives
26/// the indicator a one-bar lag — the classic Crabel formulation. A long signal
27/// fires the bar after price closes above the high-SMA; the stop then trails
28/// at the low-SMA. The first input that fills the SMA window seeds a long.
29/// A common configuration is a `3`-period window.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Candle, Indicator, HiLoActivator};
35///
36/// let mut indicator = HiLoActivator::new(3).unwrap();
37/// let mut last = None;
38/// for i in 0..40 {
39///     let base = 100.0 + f64::from(i);
40///     let candle =
41///         Candle::new(base, base + 1.0, base - 1.0, base, 10.0, i64::from(i)).unwrap();
42///     last = indicator.update(candle);
43/// }
44/// assert!(last.is_some());
45/// ```
46#[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    /// Last bar's `(hi_sma, lo_sma)`, used so today's signal is based on
54    /// yesterday's SMAs (no look-ahead).
55    prev_smas: Option<(f64, f64)>,
56    /// `true` while the current trail is on the long side.
57    long: bool,
58    /// `true` once a signal has been emitted at least once.
59    started: bool,
60}
61
62impl HiLoActivator {
63    /// Construct a `HiLo` Activator with an explicit SMA window.
64    ///
65    /// # Errors
66    /// Returns [`Error::PeriodZero`] if `period == 0`.
67    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    /// Crabel's classic configuration: a `3`-bar window.
89    pub fn classic() -> Self {
90        Self::new(3).expect("classic period is valid")
91    }
92
93    /// Configured SMA window.
94    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        // Need today's SMA + yesterday's SMA to compare close vs the *previous*
121        // bar's bands — so the very first ready bar only computes today's SMA
122        // and stores it; emission begins on the next bar.
123        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            // First SMA-ready bar seeds yesterday's bands for the next call.
144            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        // The first 3 candles fill the SMA; the 4th is the first emission.
204        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        // Flat candles: H=11, L=9, C=10. Both SMAs are constant.
221        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            // close (10) is not > 11 nor < 9, so the long seed persists -> lo_sma = 9.
224            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}