Skip to main content

wickra_core/indicators/
elder_impulse.rs

1//! Elder Impulse System.
2
3use crate::error::{Error, Result};
4use crate::indicators::ema::Ema;
5use crate::indicators::macd::MacdIndicator;
6use crate::traits::Indicator;
7
8/// Alexander Elder's Impulse System — a tri-state momentum gauge combining the
9/// slope of an `EMA` trend filter with the slope of the `MACD` histogram.
10///
11/// On each bar Wickra reports:
12///
13/// - `+1` ("green / buy") when both the `EMA` trend and the `MACD` histogram
14///   are rising bar-over-bar.
15/// - `−1` ("red / sell") when both are falling.
16/// - `0` ("blue / neutral") when the two disagree.
17///
18/// The defaults track Elder's *Come Into My Trading Room* parameterisation:
19/// `EMA(13)` for the trend, `MACD(12, 26, 9)` for the histogram.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{ElderImpulse, Indicator};
25///
26/// let mut elder = ElderImpulse::classic();
27/// let mut last = None;
28/// for i in 0..120 {
29///     last = elder.update(100.0 + f64::from(i));
30/// }
31/// assert!(last.is_some());
32/// ```
33#[derive(Debug, Clone)]
34pub struct ElderImpulse {
35    ema_period: usize,
36    macd_fast: usize,
37    macd_slow: usize,
38    macd_signal: usize,
39    ema: Ema,
40    macd: MacdIndicator,
41    prev_ema: Option<f64>,
42    prev_hist: Option<f64>,
43    current: Option<f64>,
44}
45
46impl ElderImpulse {
47    /// # Errors
48    /// Forwarded from [`Ema::new`] / [`MacdIndicator::new`].
49    pub fn new(
50        ema_period: usize,
51        macd_fast: usize,
52        macd_slow: usize,
53        macd_signal: usize,
54    ) -> Result<Self> {
55        if ema_period == 0 {
56            return Err(Error::PeriodZero);
57        }
58        Ok(Self {
59            ema_period,
60            macd_fast,
61            macd_slow,
62            macd_signal,
63            ema: Ema::new(ema_period)?,
64            macd: MacdIndicator::new(macd_fast, macd_slow, macd_signal)?,
65            prev_ema: None,
66            prev_hist: None,
67            current: None,
68        })
69    }
70
71    /// Elder's recommended defaults `(ema_period = 13, macd = 12/26/9)`.
72    pub fn classic() -> Self {
73        Self::new(13, 12, 26, 9).expect("classic Elder Impulse parameters are valid")
74    }
75
76    /// Configured `(ema_period, macd_fast, macd_slow, macd_signal)`.
77    pub const fn periods(&self) -> (usize, usize, usize, usize) {
78        (
79            self.ema_period,
80            self.macd_fast,
81            self.macd_slow,
82            self.macd_signal,
83        )
84    }
85}
86
87impl Indicator for ElderImpulse {
88    type Input = f64;
89    type Output = f64;
90
91    fn update(&mut self, input: f64) -> Option<f64> {
92        // Feed both branches on every input so they warm in parallel.
93        let ema_now = self.ema.update(input);
94        let macd_now = self.macd.update(input);
95        let (ema_now, macd_now) = (ema_now?, macd_now?);
96
97        // The Impulse needs two consecutive readings on both branches to
98        // judge direction. The first ready bar seeds prev_*; the second emits.
99        let prev_ema = self.prev_ema;
100        let prev_hist = self.prev_hist;
101        self.prev_ema = Some(ema_now);
102        self.prev_hist = Some(macd_now.histogram);
103        let prev_ema = prev_ema?;
104        let prev_hist = prev_hist?;
105
106        let ema_rising = ema_now > prev_ema;
107        let ema_falling = ema_now < prev_ema;
108        let hist_rising = macd_now.histogram > prev_hist;
109        let hist_falling = macd_now.histogram < prev_hist;
110
111        let value = if ema_rising && hist_rising {
112            1.0
113        } else if ema_falling && hist_falling {
114            -1.0
115        } else {
116            0.0
117        };
118        self.current = Some(value);
119        Some(value)
120    }
121
122    fn reset(&mut self) {
123        self.ema.reset();
124        self.macd.reset();
125        self.prev_ema = None;
126        self.prev_hist = None;
127        self.current = None;
128    }
129
130    fn warmup_period(&self) -> usize {
131        // MACD's warmup is slow + signal − 1; EMA's is ema_period. The
132        // slowest branch fires the *first* impulse-ready reading, but
133        // judging direction needs one *more* bar on top.
134        let macd_warmup = self.macd_slow + self.macd_signal - 1;
135        self.ema_period.max(macd_warmup) + 1
136    }
137
138    fn is_ready(&self) -> bool {
139        self.current.is_some()
140    }
141
142    fn name(&self) -> &'static str {
143        "ElderImpulse"
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::traits::BatchExt;
151
152    #[test]
153    fn rejects_zero_period() {
154        assert!(matches!(
155            ElderImpulse::new(0, 12, 26, 9),
156            Err(Error::PeriodZero)
157        ));
158        assert!(matches!(
159            ElderImpulse::new(13, 0, 26, 9),
160            Err(Error::PeriodZero)
161        ));
162    }
163
164    #[test]
165    fn rejects_invalid_macd_params() {
166        // MacdIndicator validates fast < slow.
167        assert!(ElderImpulse::new(13, 26, 12, 9).is_err());
168    }
169
170    #[test]
171    fn accessors_and_metadata() {
172        let elder = ElderImpulse::classic();
173        assert_eq!(elder.periods(), (13, 12, 26, 9));
174        assert_eq!(elder.name(), "ElderImpulse");
175    }
176
177    #[test]
178    fn classic_factory() {
179        assert_eq!(ElderImpulse::classic().periods(), (13, 12, 26, 9));
180    }
181
182    #[test]
183    fn constant_series_yields_neutral() {
184        // Both EMA and MACD-histogram are flat on a constant series, so
185        // neither is rising nor falling -> Impulse = 0.
186        let mut elder = ElderImpulse::classic();
187        let out = elder.batch(&[42.0_f64; 120]);
188        // Take values from the post-warmup region.
189        for v in out.iter().skip(40).flatten() {
190            assert_eq!(*v, 0.0);
191        }
192    }
193
194    #[test]
195    fn pure_uptrend_signals_buy() {
196        // Monotonic uptrend: EMA rises every bar; MACD histogram is positive
197        // and (after the slow EMA catches up) also rising bar-over-bar.
198        let mut elder = ElderImpulse::classic();
199        for i in 1..=300 {
200            elder.update(f64::from(i));
201        }
202        // The final reading should be +1 (buy) or 0 — never -1 on a clean
203        // up trend.
204        let v = elder.current.unwrap();
205        assert!(v >= 0.0, "uptrend should not signal sell: {v}");
206    }
207
208    #[test]
209    fn warmup_emits_first_value_at_warmup_period() {
210        let mut elder = ElderImpulse::new(3, 2, 4, 3).unwrap();
211        // MACD warmup: 4 + 3 - 1 = 6; EMA warmup: 3; max = 6; +1 for the
212        // direction bar = 7.
213        assert_eq!(elder.warmup_period(), 7);
214        let prices: Vec<f64> = (1..=10).map(f64::from).collect();
215        let out = elder.batch(&prices);
216        for v in out.iter().take(6) {
217            assert!(v.is_none());
218        }
219        assert!(out[6].is_some());
220    }
221
222    #[test]
223    fn batch_equals_streaming() {
224        let prices: Vec<f64> = (1..=200)
225            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
226            .collect();
227        let mut a = ElderImpulse::classic();
228        let mut b = ElderImpulse::classic();
229        assert_eq!(
230            a.batch(&prices),
231            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
232        );
233    }
234
235    #[test]
236    fn reset_clears_state() {
237        let mut elder = ElderImpulse::classic();
238        elder.batch(&(1..=200).map(f64::from).collect::<Vec<_>>());
239        assert!(elder.is_ready());
240        elder.reset();
241        assert!(!elder.is_ready());
242    }
243}