Skip to main content

quant_indicators/
efficiency_ratio.rs

1//! Kaufman Efficiency Ratio (ER) indicator.
2//!
3//! Measures how efficiently price moves in one direction versus total movement.
4//!
5//! # Formula
6//!
7//! ```text
8//! ER(N) = |close[i] - close[i-N]| / sum(|close[j] - close[j-1]| for j in (i-N+1)..=i)
9//! ```
10//!
11//! - ER → 1.0: price moved efficiently in one direction (trending)
12//! - ER → 0.0: lots of movement, no directional progress (choppy)
13//!
14//! # Reference
15//!
16//! Kaufman, P.J. (1995) "Smarter Trading"
17
18use quant_primitives::Candle;
19use rust_decimal::Decimal;
20
21use crate::error::IndicatorError;
22use crate::indicator::Indicator;
23use crate::series::Series;
24
25/// Kaufman Efficiency Ratio indicator.
26#[derive(Debug, Clone)]
27pub struct EfficiencyRatio {
28    lookback: usize,
29    name: String,
30}
31
32impl EfficiencyRatio {
33    /// Create a new `EfficiencyRatio` with the specified lookback period.
34    ///
35    /// # Errors
36    ///
37    /// Returns `InvalidParameter` if lookback is 0.
38    pub fn new(lookback: usize) -> Result<Self, IndicatorError> {
39        if lookback == 0 {
40            return Err(IndicatorError::InvalidParameter {
41                message: "EfficiencyRatio lookback must be >= 1, got 0".to_string(),
42            });
43        }
44        Ok(Self {
45            lookback,
46            name: format!("ER({})", lookback),
47        })
48    }
49
50    /// Compute the ER from the last `lookback` candles.
51    ///
52    /// Returns a single Decimal in \[0, 1\].
53    pub fn compute_ratio(&self, candles: &[Candle]) -> Result<Decimal, IndicatorError> {
54        let min_required = self.lookback + 1;
55        if candles.len() < min_required {
56            return Err(IndicatorError::InsufficientData {
57                required: min_required,
58                actual: candles.len(),
59            });
60        }
61
62        let end = candles.len() - 1;
63        let start = end - self.lookback;
64        let closes: Vec<Decimal> = candles[start..=end].iter().map(|c| c.close()).collect();
65        Self::er_from_closes(&closes)
66    }
67
68    /// Compute ER from a slice of close prices.
69    ///
70    /// Requires at least `lookback + 1` close prices (same as `compute_ratio`).
71    /// Useful when only close prices are available (e.g., ring buffer).
72    pub fn compute_from_closes(&self, closes: &[Decimal]) -> Result<Decimal, IndicatorError> {
73        let min_required = self.lookback + 1;
74        if closes.len() < min_required {
75            return Err(IndicatorError::InsufficientData {
76                required: min_required,
77                actual: closes.len(),
78            });
79        }
80
81        let end = closes.len() - 1;
82        let start = end - self.lookback;
83        Self::er_from_closes(&closes[start..=end])
84    }
85
86    /// Core ER computation from a contiguous slice of close prices.
87    ///
88    /// Expects exactly `lookback + 1` values (first is the anchor, rest are the window).
89    fn er_from_closes(closes: &[Decimal]) -> Result<Decimal, IndicatorError> {
90        let net = (closes[closes.len() - 1] - closes[0]).abs();
91
92        let mut path = Decimal::ZERO;
93        for j in 1..closes.len() {
94            path += (closes[j] - closes[j - 1]).abs();
95        }
96
97        if path.is_zero() {
98            return Ok(Decimal::ZERO);
99        }
100
101        Ok(net / path)
102    }
103
104    /// Compute ER for a specific window: candles[start..=end].
105    fn er_at(candles: &[Candle], start: usize, end: usize) -> Result<Decimal, IndicatorError> {
106        let window = &candles[start..=end];
107        let net = (window[window.len() - 1].close() - window[0].close()).abs();
108
109        let mut path = Decimal::ZERO;
110        for j in 1..window.len() {
111            path += (window[j].close() - window[j - 1].close()).abs();
112        }
113
114        if path.is_zero() {
115            return Ok(Decimal::ZERO);
116        }
117
118        Ok(net / path)
119    }
120
121    /// The lookback period.
122    #[must_use]
123    pub fn lookback(&self) -> usize {
124        self.lookback
125    }
126}
127
128#[cfg(test)]
129#[path = "efficiency_ratio_tests.rs"]
130mod tests;
131
132impl Indicator for EfficiencyRatio {
133    fn name(&self) -> &str {
134        &self.name
135    }
136
137    fn warmup_period(&self) -> usize {
138        self.lookback + 1
139    }
140
141    fn compute(&self, candles: &[Candle]) -> Result<Series, IndicatorError> {
142        let min_required = self.lookback + 1;
143        if candles.len() < min_required {
144            return Err(IndicatorError::InsufficientData {
145                required: min_required,
146                actual: candles.len(),
147            });
148        }
149
150        let mut values = Vec::with_capacity(candles.len() - self.lookback);
151        for i in self.lookback..candles.len() {
152            let start = i - self.lookback;
153            let er = Self::er_at(candles, start, i)?;
154            values.push((candles[i].timestamp(), er));
155        }
156
157        Ok(Series::new(values))
158    }
159}