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    fn update(&mut self, candle: Candle) -> Option<f64> {
50        self.has_emitted = true;
51        let out = if candle.close == 0.0 {
52            // A zero close carries no scale to normalise the range against.
53            0.0
54        } else {
55            (candle.high - candle.low) / candle.close
56        };
57        Some(out)
58    }
59
60    fn reset(&mut self) {
61        self.has_emitted = false;
62    }
63
64    fn warmup_period(&self) -> usize {
65        1
66    }
67
68    fn is_ready(&self) -> bool {
69        self.has_emitted
70    }
71
72    fn name(&self) -> &'static str {
73        "HighLowRange"
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::traits::BatchExt;
81    use approx::assert_relative_eq;
82
83    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
84        Candle::new(open, high, low, close, 1.0, ts).unwrap()
85    }
86
87    #[test]
88    fn reference_value() {
89        // (104 - 98) / 100 = 0.06.
90        let mut hlr = HighLowRange::new();
91        assert_relative_eq!(
92            hlr.update(candle(99.0, 104.0, 98.0, 100.0, 0)).unwrap(),
93            0.06,
94            epsilon = 1e-12
95        );
96    }
97
98    #[test]
99    fn zero_range_bar_yields_zero() {
100        // high == low -> range 0 -> 0 regardless of close.
101        let mut hlr = HighLowRange::new();
102        assert_relative_eq!(
103            hlr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
104            0.0,
105            epsilon = 1e-12
106        );
107    }
108
109    #[test]
110    fn zero_close_yields_zero() {
111        // Candle permits a zero close (only finiteness + OHLC ordering checked):
112        // open 0, high 1, low 0, close 0 satisfies high >= all, low <= all.
113        let mut hlr = HighLowRange::new();
114        assert_relative_eq!(
115            hlr.update(candle(0.0, 1.0, 0.0, 0.0, 0)).unwrap(),
116            0.0,
117            epsilon = 1e-12
118        );
119    }
120
121    #[test]
122    fn output_is_non_negative() {
123        let candles: Vec<Candle> = (0..100)
124            .map(|i| {
125                let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
126                candle(mid, mid + 3.0, mid - 3.0, mid, i64::from(i))
127            })
128            .collect();
129        let mut hlr = HighLowRange::new();
130        for v in hlr.batch(&candles).into_iter().flatten() {
131            assert!(v >= 0.0, "HighLowRange {v} must be non-negative");
132        }
133    }
134
135    #[test]
136    fn name_metadata() {
137        let hlr = HighLowRange::new();
138        assert_eq!(hlr.name(), "HighLowRange");
139    }
140
141    #[test]
142    fn emits_from_first_candle() {
143        let mut hlr = HighLowRange::new();
144        assert_eq!(hlr.warmup_period(), 1);
145        assert!(!hlr.is_ready());
146        assert!(hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
147        assert!(hlr.is_ready());
148    }
149
150    #[test]
151    fn reset_clears_state() {
152        let mut hlr = HighLowRange::new();
153        hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
154        assert!(hlr.is_ready());
155        hlr.reset();
156        assert!(!hlr.is_ready());
157    }
158
159    #[test]
160    fn batch_equals_streaming() {
161        let candles: Vec<Candle> = (0..40)
162            .map(|i| {
163                let base = 100.0 + f64::from(i);
164                candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
165            })
166            .collect();
167        let mut a = HighLowRange::new();
168        let mut b = HighLowRange::new();
169        assert_eq!(
170            a.batch(&candles),
171            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
172        );
173    }
174}