Skip to main content

wickra_core/indicators/
high_low_index.rs

1//! High-Low Index — a moving average of the record-high percentage.
2
3use crate::cross_section::CrossSection;
4use crate::error::Result;
5use crate::traits::Indicator;
6use crate::Sma;
7
8/// High-Low Index — a simple moving average of the *record high percent*,
9/// `100 * new_highs / (new_highs + new_lows)`.
10///
11/// The record high percent is the share of new-extreme issues that are new
12/// *highs* rather than new *lows*; smoothing it over a window (the classic period
13/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate
14/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The
15/// 30 and 70 lines are watched as oversold / overbought breadth thresholds.
16///
17/// Each tick floors the new-extreme count to one, so a tick with no new highs or
18/// lows contributes a defined `0.0` instead of dividing by zero. The reading is
19/// `None` until `period` ticks have been seen.
20///
21/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
22/// `warmup_period == period`.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member};
28///
29/// let mut hli = HighLowIndex::new(2).unwrap();
30/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap();
31/// assert_eq!(hli.update(highs.clone()), None); // warming up
32/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs
33/// ```
34#[derive(Debug, Clone)]
35pub struct HighLowIndex {
36    sma: Sma,
37}
38
39impl HighLowIndex {
40    /// Construct a new High-Low Index over the given window length.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
45    pub fn new(period: usize) -> Result<Self> {
46        Ok(Self {
47            sma: Sma::new(period)?,
48        })
49    }
50
51    /// Configured window length.
52    #[must_use]
53    pub const fn period(&self) -> usize {
54        self.sma.period()
55    }
56}
57
58impl Indicator for HighLowIndex {
59    type Input = CrossSection;
60    type Output = f64;
61
62    fn update(&mut self, section: CrossSection) -> Option<f64> {
63        let new_highs = section.new_highs();
64        let new_lows = section.new_lows();
65        let extremes = (new_highs + new_lows).max(1) as f64;
66        let record_high_percent = 100.0 * new_highs as f64 / extremes;
67        self.sma.update(record_high_percent)
68    }
69
70    fn reset(&mut self) {
71        self.sma.reset();
72    }
73
74    fn warmup_period(&self) -> usize {
75        self.sma.period()
76    }
77
78    fn is_ready(&self) -> bool {
79        self.sma.value().is_some()
80    }
81
82    fn name(&self) -> &'static str {
83        "HighLowIndex"
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::cross_section::Member;
91    use crate::error::Error;
92    use crate::traits::BatchExt;
93
94    fn flags(highs: usize, lows: usize) -> CrossSection {
95        let mut members = Vec::new();
96        for _ in 0..highs {
97            members.push(Member::new(1.0, 10.0, true, false));
98        }
99        for _ in 0..lows {
100            members.push(Member::new(-1.0, 10.0, false, true));
101        }
102        members.push(Member::new(0.0, 10.0, false, false));
103        CrossSection::new(members, 0).unwrap()
104    }
105
106    #[test]
107    fn accessors_and_metadata() {
108        let hli = HighLowIndex::new(10).unwrap();
109        assert_eq!(hli.name(), "HighLowIndex");
110        assert_eq!(hli.warmup_period(), 10);
111        assert_eq!(hli.period(), 10);
112        assert!(!hli.is_ready());
113    }
114
115    #[test]
116    fn rejects_zero_period() {
117        assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero)));
118    }
119
120    #[test]
121    fn averages_the_record_high_percent() {
122        let mut hli = HighLowIndex::new(2).unwrap();
123        // 8 highs / 10 extremes -> 80% ; window not full.
124        assert_eq!(hli.update(flags(8, 2)), None);
125        // 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70.
126        let value = hli.update(flags(6, 4)).unwrap();
127        assert!((value - 70.0).abs() < 1e-9);
128        assert!(hli.is_ready());
129    }
130
131    #[test]
132    fn no_extremes_floors_to_zero_percent() {
133        let mut hli = HighLowIndex::new(1).unwrap();
134        // No new highs or lows -> 0 / max(0, 1) -> 0%.
135        assert_eq!(hli.update(flags(0, 0)), Some(0.0));
136    }
137
138    #[test]
139    fn reset_clears_state() {
140        let mut hli = HighLowIndex::new(2).unwrap();
141        hli.update(flags(8, 2));
142        hli.update(flags(6, 4));
143        assert!(hli.is_ready());
144        hli.reset();
145        assert!(!hli.is_ready());
146        assert_eq!(hli.update(flags(8, 2)), None);
147    }
148
149    #[test]
150    fn batch_equals_streaming() {
151        let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)];
152        let mut a = HighLowIndex::new(2).unwrap();
153        let mut b = HighLowIndex::new(2).unwrap();
154        assert_eq!(
155            a.batch(&sections),
156            sections
157                .iter()
158                .map(|s| b.update(s.clone()))
159                .collect::<Vec<_>>()
160        );
161    }
162}