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