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    #[inline]
65    fn update(&mut self, section: CrossSection) -> Option<f64> {
66        let oscillator = self.oscillator.step(&section);
67        self.sum += oscillator;
68        self.has_emitted = true;
69        Some(self.sum)
70    }
71
72    fn reset(&mut self) {
73        self.oscillator.reset();
74        self.sum = 0.0;
75        self.has_emitted = false;
76    }
77
78    #[inline]
79    fn warmup_period(&self) -> usize {
80        1
81    }
82
83    #[inline]
84    fn is_ready(&self) -> bool {
85        self.has_emitted
86    }
87
88    #[inline]
89    fn name(&self) -> &'static str {
90        "McClellanSummationIndex"
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::cross_section::Member;
98    use crate::traits::BatchExt;
99
100    fn section(up: usize, down: usize) -> CrossSection {
101        let mut members = Vec::new();
102        for _ in 0..up {
103            members.push(Member::new(1.0, 10.0, false, false));
104        }
105        for _ in 0..down {
106            members.push(Member::new(-1.0, 10.0, false, false));
107        }
108        members.push(Member::new(0.0, 10.0, false, false));
109        CrossSection::new(members, 0).unwrap()
110    }
111
112    #[test]
113    fn accessors_and_metadata() {
114        let msi = McClellanSummationIndex::new();
115        assert_eq!(msi.name(), "McClellanSummationIndex");
116        assert_eq!(msi.warmup_period(), 1);
117        assert!(!msi.is_ready());
118    }
119
120    #[test]
121    fn first_tick_starts_at_zero() {
122        let mut msi = McClellanSummationIndex::new();
123        assert_eq!(msi.update(section(3, 1)), Some(0.0));
124        assert!(msi.is_ready());
125    }
126
127    #[test]
128    fn accumulates_the_oscillator() {
129        let mut msi = McClellanSummationIndex::new();
130        assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0
131                                                          // osc -50 -> sum -50.
132        let value = msi.update(section(1, 3)).unwrap();
133        assert!((value - (-50.0)).abs() < 1e-9);
134        // osc -67.5 -> sum -117.5.
135        let value = msi.update(section(2, 2)).unwrap();
136        assert!((value - (-117.5)).abs() < 1e-9);
137    }
138
139    #[test]
140    fn reset_clears_state() {
141        let mut msi = McClellanSummationIndex::new();
142        msi.update(section(3, 1));
143        msi.update(section(1, 3));
144        assert!(msi.is_ready());
145        msi.reset();
146        assert!(!msi.is_ready());
147        // Oscillator re-seeds, so the summation index restarts at 0.
148        assert_eq!(msi.update(section(1, 3)), Some(0.0));
149    }
150
151    #[test]
152    fn batch_equals_streaming() {
153        let sections = vec![section(3, 1), section(1, 3), section(2, 2)];
154        let mut a = McClellanSummationIndex::new();
155        let mut b = McClellanSummationIndex::new();
156        assert_eq!(
157            a.batch(&sections),
158            sections
159                .iter()
160                .map(|s| b.update(s.clone()))
161                .collect::<Vec<_>>()
162        );
163    }
164}