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