Skip to main content

wickra_core/indicators/
high_low_range.rs

1//! High-Low Range — the bar range as a fraction of close.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// High-Low Range — the bar's high-low range expressed as a fraction of its
7/// close price.
8///
9/// ```text
10/// HighLowRange = (high − low) / close
11/// ```
12///
13/// A scale-free, single-bar volatility proxy: the absolute range `high − low`
14/// grows with the nominal price level, so dividing by the close makes a `2$`
15/// range on a `100$` instrument (`0.02`) directly comparable to a `200$` range
16/// on a `10000$` one (`0.02`). It is the per-bar cousin of average-true-range
17/// style measures without the smoothing — useful as an instant intrabar
18/// volatility read or a normaliser for other features. The output is `≥ 0`
19/// for positive prices. A zero close carries no scale and yields `0`.
20///
21/// This is a stateless per-bar transform: every candle produces one value.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Candle, Indicator, HighLowRange};
27///
28/// let mut indicator = HighLowRange::new();
29/// // range 104 - 98 = 6, close 100 -> 0.06.
30/// let c = Candle::new(99.0, 104.0, 98.0, 100.0, 10.0, 0).unwrap();
31/// assert!((indicator.update(c).unwrap() - 0.06).abs() < 1e-12);
32/// ```
33#[derive(Debug, Clone, Default)]
34pub struct HighLowRange {
35    has_emitted: bool,
36}
37
38impl HighLowRange {
39    /// Construct a new High-Low Range transform.
40    pub const fn new() -> Self {
41        Self { has_emitted: false }
42    }
43}
44
45impl Indicator for HighLowRange {
46    type Input = Candle;
47    type Output = f64;
48
49    #[inline]
50    fn update(&mut self, candle: Candle) -> Option<f64> {
51        self.has_emitted = true;
52        let out = if candle.close == 0.0 {
53            // A zero close carries no scale to normalise the range against.
54            0.0
55        } else {
56            (candle.high - candle.low) / candle.close
57        };
58        Some(out)
59    }
60
61    fn reset(&mut self) {
62        self.has_emitted = false;
63    }
64
65    #[inline]
66    fn warmup_period(&self) -> usize {
67        1
68    }
69
70    #[inline]
71    fn is_ready(&self) -> bool {
72        self.has_emitted
73    }
74
75    #[inline]
76    fn name(&self) -> &'static str {
77        "HighLowRange"
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::traits::BatchExt;
85    use approx::assert_relative_eq;
86
87    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
88        Candle::new(open, high, low, close, 1.0, ts).unwrap()
89    }
90
91    #[test]
92    fn reference_value() {
93        // (104 - 98) / 100 = 0.06.
94        let mut hlr = HighLowRange::new();
95        assert_relative_eq!(
96            hlr.update(candle(99.0, 104.0, 98.0, 100.0, 0)).unwrap(),
97            0.06,
98            epsilon = 1e-12
99        );
100    }
101
102    #[test]
103    fn zero_range_bar_yields_zero() {
104        // high == low -> range 0 -> 0 regardless of close.
105        let mut hlr = HighLowRange::new();
106        assert_relative_eq!(
107            hlr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
108            0.0,
109            epsilon = 1e-12
110        );
111    }
112
113    #[test]
114    fn zero_close_yields_zero() {
115        // Candle permits a zero close (only finiteness + OHLC ordering checked):
116        // open 0, high 1, low 0, close 0 satisfies high >= all, low <= all.
117        let mut hlr = HighLowRange::new();
118        assert_relative_eq!(
119            hlr.update(candle(0.0, 1.0, 0.0, 0.0, 0)).unwrap(),
120            0.0,
121            epsilon = 1e-12
122        );
123    }
124
125    #[test]
126    fn output_is_non_negative() {
127        let candles: Vec<Candle> = (0..100)
128            .map(|i| {
129                let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
130                candle(mid, mid + 3.0, mid - 3.0, mid, i64::from(i))
131            })
132            .collect();
133        let mut hlr = HighLowRange::new();
134        for v in hlr.batch(&candles).into_iter().flatten() {
135            assert!(v >= 0.0, "HighLowRange {v} must be non-negative");
136        }
137    }
138
139    #[test]
140    fn name_metadata() {
141        let hlr = HighLowRange::new();
142        assert_eq!(hlr.name(), "HighLowRange");
143    }
144
145    #[test]
146    fn emits_from_first_candle() {
147        let mut hlr = HighLowRange::new();
148        assert_eq!(hlr.warmup_period(), 1);
149        assert!(!hlr.is_ready());
150        assert!(hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
151        assert!(hlr.is_ready());
152    }
153
154    #[test]
155    fn reset_clears_state() {
156        let mut hlr = HighLowRange::new();
157        hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
158        assert!(hlr.is_ready());
159        hlr.reset();
160        assert!(!hlr.is_ready());
161    }
162
163    #[test]
164    fn batch_equals_streaming() {
165        let candles: Vec<Candle> = (0..40)
166            .map(|i| {
167                let base = 100.0 + f64::from(i);
168                candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
169            })
170            .collect();
171        let mut a = HighLowRange::new();
172        let mut b = HighLowRange::new();
173        assert_eq!(
174            a.batch(&candles),
175            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
176        );
177    }
178}