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    fn update(&mut self, section: CrossSection) -> Option<f64> {
102        Some(self.step(&section))
103    }
104
105    fn reset(&mut self) {
106        self.ema_fast = 0.0;
107        self.ema_slow = 0.0;
108        self.seeded = false;
109        self.has_emitted = false;
110    }
111
112    fn warmup_period(&self) -> usize {
113        1
114    }
115
116    fn is_ready(&self) -> bool {
117        self.has_emitted
118    }
119
120    fn name(&self) -> &'static str {
121        "McClellanOscillator"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::cross_section::Member;
129    use crate::traits::BatchExt;
130
131    fn section(up: usize, down: usize) -> CrossSection {
132        let mut members = Vec::new();
133        for _ in 0..up {
134            members.push(Member::new(1.0, 10.0, false, false));
135        }
136        for _ in 0..down {
137            members.push(Member::new(-1.0, 10.0, false, false));
138        }
139        members.push(Member::new(0.0, 10.0, false, false));
140        CrossSection::new(members, 0).unwrap()
141    }
142
143    #[test]
144    fn accessors_and_metadata() {
145        let osc = McClellanOscillator::new();
146        assert_eq!(osc.name(), "McClellanOscillator");
147        assert_eq!(osc.warmup_period(), 1);
148        assert!(!osc.is_ready());
149    }
150
151    #[test]
152    fn seeds_to_zero_on_first_tick() {
153        let mut osc = McClellanOscillator::new();
154        // RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0.
155        assert_eq!(osc.update(section(3, 1)), Some(0.0));
156        assert!(osc.is_ready());
157    }
158
159    #[test]
160    fn tracks_breadth_momentum_after_seeding() {
161        let mut osc = McClellanOscillator::new();
162        osc.update(section(3, 1)); // seed at RANA 500
163                                   // RANA = (1 - 3) / 4 * 1000 = -500.
164                                   // fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450.
165        let value = osc.update(section(1, 3)).unwrap();
166        assert!((value - (-50.0)).abs() < 1e-9);
167        // RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5.
168        let value = osc.update(section(2, 2)).unwrap();
169        assert!((value - (-67.5)).abs() < 1e-9);
170    }
171
172    #[test]
173    fn empty_participation_yields_zero_rana() {
174        let mut osc = McClellanOscillator::new();
175        // No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0.
176        assert_eq!(osc.update(section(0, 0)), Some(0.0));
177    }
178
179    #[test]
180    fn reset_clears_state() {
181        let mut osc = McClellanOscillator::new();
182        osc.update(section(3, 1));
183        osc.update(section(1, 3));
184        assert!(osc.is_ready());
185        osc.reset();
186        assert!(!osc.is_ready());
187        // After reset the next tick re-seeds to spread 0.
188        assert_eq!(osc.update(section(1, 3)), Some(0.0));
189    }
190
191    #[test]
192    fn batch_equals_streaming() {
193        let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)];
194        let mut a = McClellanOscillator::new();
195        let mut b = McClellanOscillator::new();
196        assert_eq!(
197            a.batch(&sections),
198            sections
199                .iter()
200                .map(|s| b.update(s.clone()))
201                .collect::<Vec<_>>()
202        );
203    }
204}