Skip to main content

wickra_core/indicators/
hurst_channel.rs

1//! Hurst Channel (Brian Millard / Hurst-cycle channel).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sma::Sma;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Hurst Channel output.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct HurstChannelOutput {
13    /// Upper channel: `middle + multiplier · (highest_high − lowest_low)`.
14    pub upper: f64,
15    /// Middle line: SMA of close over the period.
16    pub middle: f64,
17    /// Lower channel: `middle − multiplier · (highest_high − lowest_low)`.
18    pub lower: f64,
19}
20
21/// Hurst Channel: an SMA centerline wrapped by a rolling high-low range.
22///
23/// ```text
24/// middle = SMA(close, period)
25/// range  = max(high, period) − min(low, period)
26/// upper  = middle + multiplier · range
27/// lower  = middle − multiplier · range
28/// ```
29///
30/// The Hurst Channel sizes its envelope by the *realised* high-low range of
31/// the window — a simpler, range-based volatility proxy than Bollinger's
32/// rolling stddev or Keltner's ATR. With a `multiplier` of `0.5` the channel
33/// reduces to a centerline that hugs the midpoint of the Donchian envelope;
34/// chart vendors that follow Hurst's cycle work commonly use `period = 10` and
35/// `multiplier = 0.5` for the "inner" channel.
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Candle, HurstChannel, Indicator};
41///
42/// let mut indicator = HurstChannel::new(10, 0.5).unwrap();
43/// let mut last = None;
44/// for i in 0..30 {
45///     let base = 100.0 + f64::from(i);
46///     let candle =
47///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
48///     last = indicator.update(candle);
49/// }
50/// assert!(last.is_some());
51/// ```
52#[derive(Debug, Clone)]
53pub struct HurstChannel {
54    period: usize,
55    multiplier: f64,
56    sma: Sma,
57    highs: VecDeque<f64>,
58    lows: VecDeque<f64>,
59}
60
61impl HurstChannel {
62    /// # Errors
63    /// Returns [`Error::PeriodZero`] / [`Error::NonPositiveMultiplier`] on
64    /// invalid inputs.
65    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
66        if !multiplier.is_finite() || multiplier <= 0.0 {
67            return Err(Error::NonPositiveMultiplier);
68        }
69        Ok(Self {
70            period,
71            multiplier,
72            sma: Sma::new(period)?,
73            highs: VecDeque::with_capacity(period),
74            lows: VecDeque::with_capacity(period),
75        })
76    }
77
78    /// Configured period.
79    pub const fn period(&self) -> usize {
80        self.period
81    }
82
83    /// Configured range multiplier.
84    pub const fn multiplier(&self) -> f64 {
85        self.multiplier
86    }
87}
88
89impl Indicator for HurstChannel {
90    type Input = Candle;
91    type Output = HurstChannelOutput;
92
93    fn update(&mut self, candle: Candle) -> Option<HurstChannelOutput> {
94        if self.highs.len() == self.period {
95            self.highs.pop_front();
96            self.lows.pop_front();
97        }
98        self.highs.push_back(candle.high);
99        self.lows.push_back(candle.low);
100
101        let middle = self.sma.update(candle.close)?;
102        let hi = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
103        let lo = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
104        let range = hi - lo;
105        Some(HurstChannelOutput {
106            upper: middle + self.multiplier * range,
107            middle,
108            lower: middle - self.multiplier * range,
109        })
110    }
111
112    fn reset(&mut self) {
113        self.sma.reset();
114        self.highs.clear();
115        self.lows.clear();
116    }
117
118    fn warmup_period(&self) -> usize {
119        self.period
120    }
121
122    fn is_ready(&self) -> bool {
123        self.sma.is_ready()
124    }
125
126    fn name(&self) -> &'static str {
127        "HurstChannel"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135    use approx::assert_relative_eq;
136
137    fn c(h: f64, l: f64, cl: f64) -> Candle {
138        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
139    }
140
141    #[test]
142    fn rejects_zero_period() {
143        assert!(matches!(HurstChannel::new(0, 0.5), Err(Error::PeriodZero)));
144    }
145
146    #[test]
147    fn rejects_non_positive_multiplier() {
148        assert!(matches!(
149            HurstChannel::new(10, 0.0),
150            Err(Error::NonPositiveMultiplier)
151        ));
152        assert!(matches!(
153            HurstChannel::new(10, -0.5),
154            Err(Error::NonPositiveMultiplier)
155        ));
156        assert!(matches!(
157            HurstChannel::new(10, f64::NAN),
158            Err(Error::NonPositiveMultiplier)
159        ));
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let h = HurstChannel::new(10, 0.5).unwrap();
165        assert_eq!(h.period(), 10);
166        assert_relative_eq!(h.multiplier(), 0.5, epsilon = 1e-12);
167        assert_eq!(h.warmup_period(), 10);
168        assert_eq!(h.name(), "HurstChannel");
169    }
170
171    #[test]
172    fn flat_market_collapses_bands() {
173        let candles: Vec<Candle> = (0..20).map(|_| c(10.0, 10.0, 10.0)).collect();
174        let mut h = HurstChannel::new(5, 0.5).unwrap();
175        let last = h.batch(&candles).into_iter().flatten().last().unwrap();
176        assert_relative_eq!(last.upper, 10.0, epsilon = 1e-9);
177        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-9);
178        assert_relative_eq!(last.lower, 10.0, epsilon = 1e-9);
179    }
180
181    #[test]
182    fn upper_above_middle_above_lower() {
183        let candles: Vec<Candle> = (0..50)
184            .map(|i| {
185                let m = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
186                c(m + 1.0, m - 1.0, m)
187            })
188            .collect();
189        let mut h = HurstChannel::new(10, 0.5).unwrap();
190        for o in h.batch(&candles).into_iter().flatten() {
191            assert!(o.upper >= o.middle);
192            assert!(o.middle >= o.lower);
193        }
194    }
195
196    #[test]
197    fn batch_equals_streaming() {
198        let candles: Vec<Candle> = (0..40)
199            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
200            .collect();
201        let mut a = HurstChannel::new(10, 0.5).unwrap();
202        let mut b = HurstChannel::new(10, 0.5).unwrap();
203        assert_eq!(
204            a.batch(&candles),
205            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
206        );
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let candles: Vec<Candle> = (0..10)
212            .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
213            .collect();
214        let mut h = HurstChannel::new(5, 0.5).unwrap();
215        h.batch(&candles);
216        assert!(h.is_ready());
217        h.reset();
218        assert!(!h.is_ready());
219        assert_eq!(h.update(candles[0]), None);
220    }
221
222    /// Reference: five identical candles `(high=12, low=8, close=10)`:
223    /// SMA(close, 5) = 10, range = 12 − 8 = 4, multiplier = 0.5
224    /// upper = 10 + 0.5·4 = 12, lower = 10 − 0.5·4 = 8.
225    #[test]
226    fn reference_values() {
227        let candles: Vec<Candle> = (0..5).map(|_| c(12.0, 8.0, 10.0)).collect();
228        let mut h = HurstChannel::new(5, 0.5).unwrap();
229        let out = h.batch(&candles);
230        assert!(out[0].is_none() && out[3].is_none());
231        let v = out[4].unwrap();
232        assert_relative_eq!(v.middle, 10.0, epsilon = 1e-9);
233        assert_relative_eq!(v.upper, 12.0, epsilon = 1e-9);
234        assert_relative_eq!(v.lower, 8.0, epsilon = 1e-9);
235    }
236}