Skip to main content

nautilus_indicators/ratio/
efficiency_ratio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::fmt::Display;
17
18use anyhow::Context;
19use nautilus_core::correctness::{FAILED, check_predicate_true};
20use nautilus_model::{
21    data::{Bar, QuoteTick, TradeTick},
22    enums::PriceType,
23};
24
25use crate::indicator::Indicator;
26
27/// Calculates Kaufman's Efficiency Ratio (ER) across a rolling window.
28///
29/// The period must be at least `2`.
30///
31/// For period `n`, the ratio is:
32///
33/// `ER(t) = |P(t) - P(t - n)| / sum(|P(i) - P(i - 1)|, i = t - n + 1 to t)`
34///
35/// A full `n`-period window requires `n + 1` prices for `n` price changes. For
36/// finite inputs within the model price range, values range from `0.0` to `1.0`:
37/// lower values indicate more noise, while `1.0` indicates directional price
38/// movement without reversals.
39///
40/// For compatibility, `initialized` becomes true after `n` inputs, so the first
41/// initialized value covers the `n - 1` available price changes.
42///
43/// # References
44///
45/// - Kaufman, P. J. (1995). *Smarter Trading*. McGraw-Hill.
46#[repr(C)]
47#[derive(Debug)]
48#[cfg_attr(
49    feature = "python",
50    pyo3::pyclass(module = "nautilus_trader.indicators")
51)]
52#[cfg_attr(
53    feature = "python",
54    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
55)]
56pub struct EfficiencyRatio {
57    /// The rolling window period for the indicator (>= 2).
58    pub period: usize,
59    pub price_type: PriceType,
60    pub value: f64,
61    pub inputs: Vec<f64>,
62    pub initialized: bool,
63    deltas: Vec<f64>,
64}
65
66impl Display for EfficiencyRatio {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(f, "{}({})", self.name(), self.period)
69    }
70}
71
72impl Indicator for EfficiencyRatio {
73    fn name(&self) -> String {
74        stringify!(EfficiencyRatio).to_string()
75    }
76
77    fn has_inputs(&self) -> bool {
78        !self.inputs.is_empty()
79    }
80    fn initialized(&self) -> bool {
81        self.initialized
82    }
83
84    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
85        self.update_raw(quote.extract_price(self.price_type)?.into());
86        Ok(())
87    }
88
89    fn handle_trade(&mut self, trade: &TradeTick) {
90        self.update_raw((&trade.price).into());
91    }
92
93    fn handle_bar(&mut self, bar: &Bar) {
94        self.update_raw((&bar.close).into());
95    }
96
97    fn reset(&mut self) {
98        self.value = 0.0;
99        self.inputs.clear();
100        self.deltas.clear();
101        self.initialized = false;
102    }
103}
104
105impl EfficiencyRatio {
106    /// Creates a new [`EfficiencyRatio`] instance.
107    ///
108    /// # Panics
109    ///
110    /// Panics if `period` is less than 2 or storage for its rolling windows cannot be reserved.
111    #[must_use]
112    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
113        Self::new_checked(period, price_type).expect(FAILED)
114    }
115
116    pub(crate) fn new_checked(
117        period: usize,
118        price_type: Option<PriceType>,
119    ) -> anyhow::Result<Self> {
120        check_predicate_true(period >= 2, "`period` must be at least 2")?;
121        check_predicate_true(
122            period < usize::MAX,
123            "`period` must be less than `usize::MAX`",
124        )?;
125
126        let mut inputs = Vec::new();
127        inputs
128            .try_reserve_exact(Self::input_capacity(period))
129            .context("failed to reserve efficiency ratio input window")?;
130
131        let mut deltas = Vec::new();
132        deltas
133            .try_reserve_exact(period)
134            .context("failed to reserve efficiency ratio delta window")?;
135
136        Ok(Self {
137            period,
138            price_type: price_type.unwrap_or(PriceType::Last),
139            value: 0.0,
140            inputs,
141            deltas,
142            initialized: false,
143        })
144    }
145
146    pub fn update_raw(&mut self, value: f64) {
147        // A period of price changes requires one additional input
148        if self.inputs.len() == Self::input_capacity(self.period) {
149            self.inputs.remove(0);
150        }
151        self.inputs.push(value);
152
153        if self.inputs.len() < 2 {
154            self.value = 0.0;
155            return;
156        } else if !self.initialized && self.inputs.len() >= self.period {
157            self.initialized = true;
158        }
159        let last_diff =
160            (self.inputs[self.inputs.len() - 1] - self.inputs[self.inputs.len() - 2]).abs();
161        // Bound the deltas window to `period` as well, so the sum reflects only
162        // the last `period` absolute changes.
163        if self.deltas.len() == self.period {
164            self.deltas.remove(0);
165        }
166        self.deltas.push(last_diff);
167        let sum_deltas = self.deltas.iter().sum::<f64>();
168        let net_diff = (self.inputs[self.inputs.len() - 1] - self.inputs[0]).abs();
169        self.value = if sum_deltas == 0.0 {
170            0.0
171        } else {
172            (net_diff / sum_deltas).clamp(0.0, 1.0)
173        };
174    }
175
176    const fn input_capacity(period: usize) -> usize {
177        period.saturating_add(1)
178    }
179}
180
181#[cfg(test)]
182mod tests {
183
184    use nautilus_model::types::{PRICE_MAX, PRICE_MIN};
185    use proptest::prelude::*;
186    use rstest::rstest;
187
188    use crate::{
189        indicator::Indicator, ratio::efficiency_ratio::EfficiencyRatio, stubs::*,
190        testing::assert_approx_equal,
191    };
192
193    #[rstest]
194    fn test_efficiency_ratio_initialized(efficiency_ratio_10: EfficiencyRatio) {
195        let display_str = format!("{efficiency_ratio_10}");
196        assert_eq!(display_str, "EfficiencyRatio(10)");
197        assert_eq!(efficiency_ratio_10.period, 10);
198        assert!(!efficiency_ratio_10.initialized);
199    }
200
201    #[rstest]
202    #[case(0)]
203    #[case(1)]
204    #[should_panic(expected = "`period` must be at least 2")]
205    fn test_new_rejects_period_below_two(#[case] period: usize) {
206        let _ = EfficiencyRatio::new(period, None);
207    }
208
209    #[rstest]
210    #[case(usize::MAX, "`period` must be less than `usize::MAX`")]
211    #[case(
212        usize::MAX - 1,
213        "failed to reserve efficiency ratio input window"
214    )]
215    fn test_new_checked_rejects_unrepresentable_input_window(
216        #[case] period: usize,
217        #[case] expected: &str,
218    ) {
219        let error = EfficiencyRatio::new_checked(period, None).unwrap_err();
220
221        assert_eq!(error.to_string(), expected);
222    }
223
224    #[rstest]
225    fn test_with_correct_number_of_required_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
226        for i in 1..10 {
227            efficiency_ratio_10.update_raw(f64::from(i));
228        }
229        assert_eq!(efficiency_ratio_10.inputs.len(), 9);
230        assert!(!efficiency_ratio_10.initialized);
231        efficiency_ratio_10.update_raw(1.0);
232        assert_eq!(efficiency_ratio_10.inputs.len(), 10);
233        assert!(efficiency_ratio_10.initialized);
234    }
235
236    #[rstest]
237    fn test_value_with_one_input(mut efficiency_ratio_10: EfficiencyRatio) {
238        efficiency_ratio_10.update_raw(1.0);
239        assert_eq!(efficiency_ratio_10.value, 0.0);
240    }
241
242    #[rstest]
243    fn test_value_with_efficient_higher_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
244        let mut initial_price = 1.0;
245        for _ in 1..=10 {
246            initial_price += 0.0001;
247            efficiency_ratio_10.update_raw(initial_price);
248        }
249        assert_eq!(efficiency_ratio_10.value, 1.0);
250    }
251
252    #[rstest]
253    fn test_value_with_efficient_lower_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
254        let mut initial_price = 1.0;
255        for _ in 1..=10 {
256            initial_price -= 0.0001;
257            efficiency_ratio_10.update_raw(initial_price);
258        }
259        assert_eq!(efficiency_ratio_10.value, 1.0);
260    }
261
262    #[rstest]
263    fn test_value_with_oscillating_inputs_returns_zero(mut efficiency_ratio_10: EfficiencyRatio) {
264        efficiency_ratio_10.update_raw(1.00000);
265        efficiency_ratio_10.update_raw(1.00010);
266        efficiency_ratio_10.update_raw(1.00000);
267        efficiency_ratio_10.update_raw(0.99990);
268        efficiency_ratio_10.update_raw(1.00000);
269        assert_eq!(efficiency_ratio_10.value, 0.0);
270    }
271
272    #[rstest]
273    fn test_value_with_half_oscillating(mut efficiency_ratio_10: EfficiencyRatio) {
274        efficiency_ratio_10.update_raw(1.00000);
275        efficiency_ratio_10.update_raw(1.00020);
276        efficiency_ratio_10.update_raw(1.00010);
277        efficiency_ratio_10.update_raw(1.00030);
278        efficiency_ratio_10.update_raw(1.00020);
279        assert_approx_equal(efficiency_ratio_10.value, 0.333333333333);
280    }
281
282    #[rstest]
283    fn test_value_with_noisy_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
284        efficiency_ratio_10.update_raw(1.00000);
285        efficiency_ratio_10.update_raw(1.00010);
286        efficiency_ratio_10.update_raw(1.00008);
287        efficiency_ratio_10.update_raw(1.00007);
288        efficiency_ratio_10.update_raw(1.00012);
289        efficiency_ratio_10.update_raw(1.00005);
290        efficiency_ratio_10.update_raw(1.00015);
291        assert_approx_equal(efficiency_ratio_10.value, 0.428571428572);
292    }
293
294    #[rstest]
295    #[case([10.0, 11.0, 12.0, 13.0, 14.0, 15.0], 1.0)]
296    #[case([15.0, 14.0, 13.0, 12.0, 11.0, 10.0], 1.0)]
297    #[case([10.0, 12.0, 10.0, 12.0, 10.0, 12.0], 0.2)]
298    #[case([10.0, 11.0, 10.5, 12.0, 11.5, 13.0], 0.6)]
299    #[case([10.0, 10.0, 10.0, 10.0, 10.0, 10.0], 0.0)]
300    fn test_value_uses_period_deltas(#[case] prices: [f64; 6], #[case] expected: f64) {
301        let mut efficiency_ratio = EfficiencyRatio::new(5, None);
302
303        for price in prices {
304            efficiency_ratio.update_raw(price);
305        }
306
307        assert_approx_equal(efficiency_ratio.value, expected);
308    }
309
310    #[rstest]
311    fn test_value_bounded_after_warmup() {
312        let mut efficiency_ratio = EfficiencyRatio::new(5, None);
313
314        for price in [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 14.0] {
315            efficiency_ratio.update_raw(price);
316        }
317
318        assert_eq!(
319            efficiency_ratio.inputs,
320            vec![11.0, 12.0, 13.0, 14.0, 15.0, 14.0],
321        );
322        assert_eq!(efficiency_ratio.deltas, vec![1.0; 5]);
323        assert_approx_equal(efficiency_ratio.value, 0.6);
324    }
325
326    #[rstest]
327    fn test_update_raw_reuses_reserved_window_storage() {
328        let mut efficiency_ratio = EfficiencyRatio::new(5, None);
329        let inputs_capacity = efficiency_ratio.inputs.capacity();
330        let deltas_capacity = efficiency_ratio.deltas.capacity();
331
332        for price in 0..1_000 {
333            efficiency_ratio.update_raw(f64::from(price));
334        }
335
336        assert_eq!(efficiency_ratio.inputs.capacity(), inputs_capacity);
337        assert_eq!(efficiency_ratio.deltas.capacity(), deltas_capacity);
338        assert_eq!(efficiency_ratio.inputs.len(), 6);
339        assert_eq!(efficiency_ratio.deltas.len(), 5);
340    }
341
342    #[rstest]
343    fn test_value_clamps_rounding_above_one() {
344        let mut efficiency_ratio = EfficiencyRatio::new(2, None);
345
346        for price in [0.0, 0.002, 101_070.264] {
347            efficiency_ratio.update_raw(price);
348        }
349
350        assert_eq!(efficiency_ratio.value, 1.0);
351    }
352
353    #[rstest]
354    fn test_value_remains_bounded_at_price_limits() {
355        let period = 63;
356        let mut efficiency_ratio = EfficiencyRatio::new(period, None);
357
358        for index in 0..512 {
359            let price = if index % 2 == 0 { PRICE_MIN } else { PRICE_MAX };
360            efficiency_ratio.update_raw(price);
361
362            assert!(efficiency_ratio.value.is_finite());
363            assert!((0.0..=1.0).contains(&efficiency_ratio.value));
364        }
365
366        assert_approx_equal(efficiency_ratio.value, 1.0 / period as f64);
367    }
368
369    proptest! {
370        #[rstest]
371        fn prop_value_matches_fixed_point_reference(
372            period in 2usize..=64,
373            prices in prop::collection::vec(-1_000_000_000i64..=1_000_000_000, 257..=512),
374        ) {
375            let mut efficiency_ratio = EfficiencyRatio::new(period, None);
376
377            for (index, raw_price) in prices.iter().copied().enumerate() {
378                efficiency_ratio.update_raw(raw_price as f64 / 1_000.0);
379
380                let expected = reference_value(&prices[..=index], period);
381                let error = (efficiency_ratio.value - expected).abs();
382
383                prop_assert!(efficiency_ratio.value.is_finite());
384                prop_assert!((0.0..=1.0).contains(&efficiency_ratio.value));
385                prop_assert!(
386                    error <= 1e-12,
387                    "expected {expected}, was {}",
388                    efficiency_ratio.value,
389                );
390                prop_assert_eq!(
391                    efficiency_ratio.inputs.len(),
392                    (index + 1).min(period + 1),
393                );
394                prop_assert_eq!(efficiency_ratio.deltas.len(), index.min(period));
395                prop_assert_eq!(efficiency_ratio.initialized, index + 1 >= period);
396            }
397        }
398    }
399
400    fn reference_value(prices: &[i64], period: usize) -> f64 {
401        let window = &prices[prices.len().saturating_sub(period + 1)..];
402        let net_change = (i128::from(window[window.len() - 1]) - i128::from(window[0])).abs();
403        let total_change = window
404            .windows(2)
405            .map(|pair| (i128::from(pair[1]) - i128::from(pair[0])).abs())
406            .sum::<i128>();
407
408        if total_change == 0 {
409            0.0
410        } else {
411            net_change as f64 / total_change as f64
412        }
413    }
414
415    #[rstest]
416    fn test_reset_clears_deltas(mut efficiency_ratio_10: EfficiencyRatio) {
417        // Regression: reset must clear the deltas buffer too, otherwise stale
418        // deltas leak into the next run's sum.
419        for price in [1.0, 3.0, 6.0, 10.0, 15.0] {
420            efficiency_ratio_10.update_raw(price);
421        }
422        efficiency_ratio_10.reset();
423        assert!(efficiency_ratio_10.deltas.is_empty());
424
425        // Fresh run: two inputs of a single clean move give a ratio of 1.
426        efficiency_ratio_10.update_raw(100.0);
427        efficiency_ratio_10.update_raw(100.5);
428        assert_eq!(efficiency_ratio_10.value, 1.0);
429    }
430
431    #[rstest]
432    fn test_reset(mut efficiency_ratio_10: EfficiencyRatio) {
433        for i in 1..=10 {
434            efficiency_ratio_10.update_raw(f64::from(i));
435        }
436        assert!(efficiency_ratio_10.initialized);
437        efficiency_ratio_10.reset();
438        assert!(!efficiency_ratio_10.initialized);
439        assert_eq!(efficiency_ratio_10.value, 0.0);
440    }
441
442    #[rstest]
443    fn test_handle_quote_tick(mut efficiency_ratio_10: EfficiencyRatio) {
444        let quote_tick1 = stub_quote("1500.0", "1502.0");
445        let quote_tick2 = stub_quote("1502.0", "1504.0");
446
447        efficiency_ratio_10.handle_quote(&quote_tick1).unwrap();
448        efficiency_ratio_10.handle_quote(&quote_tick2).unwrap();
449        assert_eq!(efficiency_ratio_10.value, 1.0);
450    }
451
452    #[rstest]
453    fn test_handle_bar(mut efficiency_ratio_10: EfficiencyRatio) {
454        let bar1 = bar_ethusdt_binance_minute_bid("1500.0");
455        let bar2 = bar_ethusdt_binance_minute_bid("1510.0");
456
457        efficiency_ratio_10.handle_bar(&bar1);
458        efficiency_ratio_10.handle_bar(&bar2);
459        assert_eq!(efficiency_ratio_10.value, 1.0);
460    }
461}