Skip to main content

wickra_core/indicators/
mcclellan_summation_index.rs

1//! McClellan Summation Index — the running total of the McClellan Oscillator.
2
3use crate::cross_section::CrossSection;
4use crate::indicators::mcclellan_oscillator::McClellanOscillator;
5use crate::traits::Indicator;
6
7/// McClellan Summation Index — the running cumulative sum of the
8/// [`McClellanOscillator`].
9///
10/// Where the oscillator measures the *momentum* of breadth, the summation index
11/// integrates it into a longer-term breadth trend: it rises while the oscillator
12/// is positive and falls while it is negative, so it behaves like a slow,
13/// smoothed advance/decline line. Sustained readings far above or below zero mark
14/// strong bull or bear breadth regimes, and crosses of the zero line are read as
15/// major trend changes.
16///
17/// The index embeds a [`McClellanOscillator`] and adds its value on every tick.
18/// Because the oscillator seeds to `0.0` on the first tick, the summation index
19/// also starts at `0.0` and is defined from the first update
20/// (`warmup_period == 1`).
21///
22/// `Input = CrossSection`, `Output = f64`.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{CrossSection, Indicator, McClellanSummationIndex, Member};
28///
29/// let mut msi = McClellanSummationIndex::new();
30/// let tick = CrossSection::new(
31///     vec![
32///         Member::new(1.0, 10.0, false, false),
33///         Member::new(-1.0, 10.0, false, false),
34///     ],
35///     0,
36/// )
37/// .unwrap();
38/// // First tick: oscillator seeds to 0, so the summation index is 0.
39/// assert_eq!(msi.update(tick), Some(0.0));
40/// ```
41#[derive(Debug, Clone, Default)]
42pub struct McClellanSummationIndex {
43    oscillator: McClellanOscillator,
44    sum: f64,
45    has_emitted: bool,
46}
47
48impl McClellanSummationIndex {
49    /// Construct a new McClellan Summation Index.
50    #[must_use]
51    pub fn new() -> Self {
52        Self {
53            oscillator: McClellanOscillator::new(),
54            sum: 0.0,
55            has_emitted: false,
56        }
57    }
58}
59
60impl Indicator for McClellanSummationIndex {
61    type Input = CrossSection;
62    type Output = f64;
63
64    fn update(&mut self, section: CrossSection) -> Option<f64> {
65        let oscillator = self.oscillator.step(&section);
66        self.sum += oscillator;
67        self.has_emitted = true;
68        Some(self.sum)
69    }
70
71    fn reset(&mut self) {
72        self.oscillator.reset();
73        self.sum = 0.0;
74        self.has_emitted = false;
75    }
76
77    fn warmup_period(&self) -> usize {
78        1
79    }
80
81    fn is_ready(&self) -> bool {
82        self.has_emitted
83    }
84
85    fn name(&self) -> &'static str {
86        "McClellanSummationIndex"
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::cross_section::Member;
94    use crate::traits::BatchExt;
95
96    fn section(up: usize, down: usize) -> CrossSection {
97        let mut members = Vec::new();
98        for _ in 0..up {
99            members.push(Member::new(1.0, 10.0, false, false));
100        }
101        for _ in 0..down {
102            members.push(Member::new(-1.0, 10.0, false, false));
103        }
104        members.push(Member::new(0.0, 10.0, false, false));
105        CrossSection::new(members, 0).unwrap()
106    }
107
108    #[test]
109    fn accessors_and_metadata() {
110        let msi = McClellanSummationIndex::new();
111        assert_eq!(msi.name(), "McClellanSummationIndex");
112        assert_eq!(msi.warmup_period(), 1);
113        assert!(!msi.is_ready());
114    }
115
116    #[test]
117    fn first_tick_starts_at_zero() {
118        let mut msi = McClellanSummationIndex::new();
119        assert_eq!(msi.update(section(3, 1)), Some(0.0));
120        assert!(msi.is_ready());
121    }
122
123    #[test]
124    fn accumulates_the_oscillator() {
125        let mut msi = McClellanSummationIndex::new();
126        assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0
127                                                          // osc -50 -> sum -50.
128        let value = msi.update(section(1, 3)).unwrap();
129        assert!((value - (-50.0)).abs() < 1e-9);
130        // osc -67.5 -> sum -117.5.
131        let value = msi.update(section(2, 2)).unwrap();
132        assert!((value - (-117.5)).abs() < 1e-9);
133    }
134
135    #[test]
136    fn reset_clears_state() {
137        let mut msi = McClellanSummationIndex::new();
138        msi.update(section(3, 1));
139        msi.update(section(1, 3));
140        assert!(msi.is_ready());
141        msi.reset();
142        assert!(!msi.is_ready());
143        // Oscillator re-seeds, so the summation index restarts at 0.
144        assert_eq!(msi.update(section(1, 3)), Some(0.0));
145    }
146
147    #[test]
148    fn batch_equals_streaming() {
149        let sections = vec![section(3, 1), section(1, 3), section(2, 2)];
150        let mut a = McClellanSummationIndex::new();
151        let mut b = McClellanSummationIndex::new();
152        assert_eq!(
153            a.batch(&sections),
154            sections
155                .iter()
156                .map(|s| b.update(s.clone()))
157                .collect::<Vec<_>>()
158        );
159    }
160}