Skip to main content

wickra_core/indicators/
mcclellan_oscillator.rs

1//! McClellan Oscillator — the spread between a fast and slow EMA of breadth.
2
3use crate::cross_section::CrossSection;
4use crate::traits::Indicator;
5
6/// Fast EMA smoothing constant — the classic McClellan 19-period weight
7/// `2 / (19 + 1)`.
8const ALPHA_FAST: f64 = 0.1;
9/// Slow EMA smoothing constant — the classic McClellan 39-period weight
10/// `2 / (39 + 1)`.
11const ALPHA_SLOW: f64 = 0.05;
12/// Scale applied to the ratio-adjusted net advances so readings land on the
13/// familiar McClellan amplitude.
14const RANA_SCALE: f64 = 1000.0;
15
16/// McClellan Oscillator — the difference between a 19-period and a 39-period
17/// exponential moving average of *ratio-adjusted net advances*.
18///
19/// Each tick's breadth is reduced to ratio-adjusted net advances (RANA),
20/// `(advancers - decliners) / (advancers + decliners) * 1000`. Dividing by the
21/// number of participating issues makes the reading independent of universe size,
22/// so the oscillator stays comparable as the universe grows or shrinks. The
23/// oscillator is then the fast EMA minus the slow EMA of that series, using the
24/// classic McClellan smoothing constants `0.10` (19-period) and `0.05`
25/// (39-period). Both EMAs are seeded from the first tick's RANA, so the
26/// oscillator is defined from the first update (`warmup_period == 1`); it starts
27/// at `0.0` and crosses zero as breadth momentum shifts.
28///
29/// A tick with no advancing or declining issues yields a RANA of `0.0` (the
30/// participating count is floored to one).
31///
32/// `Input = CrossSection`, `Output = f64`.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{CrossSection, Indicator, McClellanOscillator, Member};
38///
39/// let mut osc = McClellanOscillator::new();
40/// let tick = CrossSection::new(
41///     vec![
42///         Member::new(1.0, 10.0, false, false),
43///         Member::new(1.0, 10.0, false, false),
44///         Member::new(1.0, 10.0, false, false),
45///         Member::new(-1.0, 10.0, false, false),
46///     ],
47///     0,
48/// )
49/// .unwrap();
50/// // First tick seeds both EMAs to the same value -> oscillator 0.
51/// assert_eq!(osc.update(tick), Some(0.0));
52/// ```
53#[derive(Debug, Clone, Default)]
54pub struct McClellanOscillator {
55    ema_fast: f64,
56    ema_slow: f64,
57    seeded: bool,
58    has_emitted: bool,
59}
60
61impl McClellanOscillator {
62    /// Construct a new McClellan Oscillator with the classic 19/39 smoothing.
63    #[must_use]
64    pub const fn new() -> Self {
65        Self {
66            ema_fast: 0.0,
67            ema_slow: 0.0,
68            seeded: false,
69            has_emitted: false,
70        }
71    }
72
73    /// Feed a cross-section tick and return the oscillator value, which is defined
74    /// on every tick. Shared with [`McClellanSummationIndex`] so the summation
75    /// index can accumulate the oscillator without an `Option` round-trip.
76    ///
77    /// [`McClellanSummationIndex`]: crate::McClellanSummationIndex
78    pub(crate) fn step(&mut self, section: &CrossSection) -> f64 {
79        let advancers = section.advancers();
80        let decliners = section.decliners();
81        let net = advancers as f64 - decliners as f64;
82        let participating = (advancers + decliners).max(1) as f64;
83        let rana = net / participating * RANA_SCALE;
84        if self.seeded {
85            self.ema_fast += ALPHA_FAST * (rana - self.ema_fast);
86            self.ema_slow += ALPHA_SLOW * (rana - self.ema_slow);
87        } else {
88            self.ema_fast = rana;
89            self.ema_slow = rana;
90            self.seeded = true;
91        }
92        self.has_emitted = true;
93        self.ema_fast - self.ema_slow
94    }
95}
96
97impl Indicator for McClellanOscillator {
98    type Input = CrossSection;
99    type Output = f64;
100
101    #[inline]
102    fn update(&mut self, section: CrossSection) -> Option<f64> {
103        Some(self.step(&section))
104    }
105
106    fn reset(&mut self) {
107        self.ema_fast = 0.0;
108        self.ema_slow = 0.0;
109        self.seeded = false;
110        self.has_emitted = false;
111    }
112
113    #[inline]
114    fn warmup_period(&self) -> usize {
115        1
116    }
117
118    #[inline]
119    fn is_ready(&self) -> bool {
120        self.has_emitted
121    }
122
123    #[inline]
124    fn name(&self) -> &'static str {
125        "McClellanOscillator"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::cross_section::Member;
133    use crate::traits::BatchExt;
134
135    fn section(up: usize, down: usize) -> CrossSection {
136        let mut members = Vec::new();
137        for _ in 0..up {
138            members.push(Member::new(1.0, 10.0, false, false));
139        }
140        for _ in 0..down {
141            members.push(Member::new(-1.0, 10.0, false, false));
142        }
143        members.push(Member::new(0.0, 10.0, false, false));
144        CrossSection::new(members, 0).unwrap()
145    }
146
147    #[test]
148    fn accessors_and_metadata() {
149        let osc = McClellanOscillator::new();
150        assert_eq!(osc.name(), "McClellanOscillator");
151        assert_eq!(osc.warmup_period(), 1);
152        assert!(!osc.is_ready());
153    }
154
155    #[test]
156    fn seeds_to_zero_on_first_tick() {
157        let mut osc = McClellanOscillator::new();
158        // RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0.
159        assert_eq!(osc.update(section(3, 1)), Some(0.0));
160        assert!(osc.is_ready());
161    }
162
163    #[test]
164    fn tracks_breadth_momentum_after_seeding() {
165        let mut osc = McClellanOscillator::new();
166        osc.update(section(3, 1)); // seed at RANA 500
167                                   // RANA = (1 - 3) / 4 * 1000 = -500.
168                                   // fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450.
169        let value = osc.update(section(1, 3)).unwrap();
170        assert!((value - (-50.0)).abs() < 1e-9);
171        // RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5.
172        let value = osc.update(section(2, 2)).unwrap();
173        assert!((value - (-67.5)).abs() < 1e-9);
174    }
175
176    #[test]
177    fn empty_participation_yields_zero_rana() {
178        let mut osc = McClellanOscillator::new();
179        // No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0.
180        assert_eq!(osc.update(section(0, 0)), Some(0.0));
181    }
182
183    #[test]
184    fn reset_clears_state() {
185        let mut osc = McClellanOscillator::new();
186        osc.update(section(3, 1));
187        osc.update(section(1, 3));
188        assert!(osc.is_ready());
189        osc.reset();
190        assert!(!osc.is_ready());
191        // After reset the next tick re-seeds to spread 0.
192        assert_eq!(osc.update(section(1, 3)), Some(0.0));
193    }
194
195    #[test]
196    fn batch_equals_streaming() {
197        let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)];
198        let mut a = McClellanOscillator::new();
199        let mut b = McClellanOscillator::new();
200        assert_eq!(
201            a.batch(&sections),
202            sections
203                .iter()
204                .map(|s| b.update(s.clone()))
205                .collect::<Vec<_>>()
206        );
207    }
208}