Skip to main content

indicators/volatility/
elder_ray_index.rs

1//! Elder Ray Index.
2//!
3//! Python source: `indicators/other/elder_ray_index.py :: class ElderRayIndexIndicator`
4//!
5//! # Python algorithm (to port)
6//! ```python
7//! ema        = data["Close"].ewm(span=self.fast_period, adjust=False).mean()
8//! bull_power = data["High"] - ema
9//! bear_power = data["Low"]  - ema
10//! ```
11//!
12//! Output columns: `"ElderRay_bull"`, `"ElderRay_bear"`.
13
14use std::collections::HashMap;
15
16use crate::error::IndicatorError;
17use crate::functions::{self};
18use crate::indicator::{Indicator, IndicatorOutput};
19use crate::registry::param_usize;
20use crate::types::Candle;
21
22#[derive(Debug, Clone)]
23pub struct ElderRayParams {
24    /// EMA period for the base line.  Python default: 14.
25    pub fast_period: usize,
26}
27impl Default for ElderRayParams {
28    fn default() -> Self {
29        Self { fast_period: 14 }
30    }
31}
32
33#[derive(Debug, Clone)]
34pub struct ElderRayIndex {
35    pub params: ElderRayParams,
36}
37
38impl ElderRayIndex {
39    pub fn new(params: ElderRayParams) -> Self {
40        Self { params }
41    }
42    pub fn with_period(period: usize) -> Self {
43        Self::new(ElderRayParams {
44            fast_period: period,
45        })
46    }
47}
48
49impl Indicator for ElderRayIndex {
50    fn name(&self) -> &'static str {
51        "ElderRayIndex"
52    }
53    fn required_len(&self) -> usize {
54        self.params.fast_period
55    }
56    fn required_columns(&self) -> &[&'static str] {
57        &["high", "low", "close"]
58    }
59
60    /// Ports `ema = Close.ewm(span=period, adjust=False).mean()` then
61    /// `bull = High - ema` and `bear = Low - ema`.
62    ///
63    /// # Note on EMA seeding
64    /// Python's `ewm(adjust=False)` seeds the EMA with the very first close
65    /// value and emits a value for every bar (no leading `NaN` warm-up).
66    /// `functions::ema` may use a different seeding strategy (e.g. SMA over
67    /// the first `period` bars), so the first `fast_period - 1` rows can
68    /// differ slightly between the two implementations.
69    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
70        self.check_len(candles)?;
71
72        let close: Vec<f64> = candles.iter().map(|c| c.close).collect();
73        let high: Vec<f64> = candles.iter().map(|c| c.high).collect();
74        let low: Vec<f64> = candles.iter().map(|c| c.low).collect();
75
76        // Use ema_nan_aware to match Python's ewm(span=period, adjust=False),
77        // which seeds from the first close value rather than an SMA over the
78        // first `period` bars.  This aligns with the Python docstring above.
79        let ema = functions::ema_nan_aware(&close, self.params.fast_period)?;
80
81        let bull: Vec<f64> = high.iter().zip(&ema).map(|(&h, &e)| h - e).collect();
82        let bear: Vec<f64> = low.iter().zip(&ema).map(|(&l, &e)| l - e).collect();
83
84        Ok(IndicatorOutput::from_pairs([
85            ("ElderRay_bull".to_string(), bull),
86            ("ElderRay_bear".to_string(), bear),
87        ]))
88    }
89}
90
91pub fn factory<S: ::std::hash::BuildHasher>(
92    params: &HashMap<String, String, S>,
93) -> Result<Box<dyn Indicator>, IndicatorError> {
94    Ok(Box::new(ElderRayIndex::new(ElderRayParams {
95        fast_period: param_usize(params, "fast_period", 14)?,
96    })))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn candles(n: usize) -> Vec<Candle> {
104        (0..n)
105            .map(|i| Candle {
106                time: i64::try_from(i).expect("time index fits i64"),
107                open: 10.0,
108                high: 12.0,
109                low: 8.0,
110                close: 10.0 + i as f64 * 0.1,
111                volume: 100.0,
112            })
113            .collect()
114    }
115
116    #[test]
117    fn elder_ray_two_columns() {
118        let out = ElderRayIndex::with_period(14)
119            .calculate(&candles(20))
120            .unwrap();
121        assert!(out.get("ElderRay_bull").is_some());
122        assert!(out.get("ElderRay_bear").is_some());
123    }
124
125    #[test]
126    fn bull_power_is_high_minus_ema() {
127        // Bull power must always be >= bear power (high >= low).
128        let out = ElderRayIndex::with_period(5)
129            .calculate(&candles(20))
130            .unwrap();
131        let bull = out.get("ElderRay_bull").unwrap();
132        let bear = out.get("ElderRay_bear").unwrap();
133        for i in 5..20 {
134            if !bull[i].is_nan() {
135                assert!(bull[i] >= bear[i], "bull < bear at {i}");
136            }
137        }
138    }
139
140    #[test]
141    fn factory_creates_elder_ray() {
142        assert_eq!(factory(&HashMap::new()).unwrap().name(), "ElderRayIndex");
143    }
144}