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 positive.
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 (>= 1).
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 zero 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 > 0, "`period` must be positive")?;
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        if !value.is_finite() {
148            return;
149        }
150
151        // A period of price changes requires one additional input
152        if self.inputs.len() == Self::input_capacity(self.period) {
153            self.inputs.remove(0);
154        }
155        self.inputs.push(value);
156
157        if self.inputs.len() < 2 {
158            self.value = 0.0;
159            return;
160        } else if !self.initialized && self.inputs.len() >= self.period {
161            self.initialized = true;
162        }
163        let last_diff =
164            (self.inputs[self.inputs.len() - 1] - self.inputs[self.inputs.len() - 2]).abs();
165        // Bound the deltas window to `period` as well, so the sum reflects only
166        // the last `period` absolute changes.
167        if self.deltas.len() == self.period {
168            self.deltas.remove(0);
169        }
170        self.deltas.push(last_diff);
171        let sum_deltas = self.deltas.iter().sum::<f64>();
172        let net_diff = (self.inputs[self.inputs.len() - 1] - self.inputs[0]).abs();
173        self.value = if sum_deltas == 0.0 {
174            0.0
175        } else {
176            (net_diff / sum_deltas).clamp(0.0, 1.0)
177        };
178    }
179
180    const fn input_capacity(period: usize) -> usize {
181        period.saturating_add(1)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187
188    use nautilus_model::types::{PRICE_MAX, PRICE_MIN};
189    use proptest::prelude::*;
190    use rstest::rstest;
191
192    use crate::{
193        indicator::Indicator, ratio::efficiency_ratio::EfficiencyRatio, stubs::*,
194        testing::assert_approx_equal,
195    };
196
197    #[rstest]
198    fn test_efficiency_ratio_initialized(efficiency_ratio_10: EfficiencyRatio) {
199        let display_str = format!("{efficiency_ratio_10}");
200        assert_eq!(display_str, "EfficiencyRatio(10)");
201        assert_eq!(efficiency_ratio_10.period, 10);
202        assert!(!efficiency_ratio_10.initialized);
203    }
204
205    #[rstest]
206    #[case(0)]
207    #[should_panic(expected = "`period` must be positive")]
208    fn test_new_rejects_zero_period(#[case] period: usize) {
209        let _ = EfficiencyRatio::new(period, None);
210    }
211
212    #[rstest]
213    #[case(usize::MAX, "`period` must be less than `usize::MAX`")]
214    #[case(
215        usize::MAX - 1,
216        "failed to reserve efficiency ratio input window"
217    )]
218    fn test_new_checked_rejects_unrepresentable_input_window(
219        #[case] period: usize,
220        #[case] expected: &str,
221    ) {
222        let error = EfficiencyRatio::new_checked(period, None).unwrap_err();
223
224        assert_eq!(error.to_string(), expected);
225    }
226
227    #[rstest]
228    fn test_with_correct_number_of_required_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
229        for i in 1..10 {
230            efficiency_ratio_10.update_raw(f64::from(i));
231        }
232        assert_eq!(efficiency_ratio_10.inputs.len(), 9);
233        assert!(!efficiency_ratio_10.initialized);
234        efficiency_ratio_10.update_raw(1.0);
235        assert_eq!(efficiency_ratio_10.inputs.len(), 10);
236        assert!(efficiency_ratio_10.initialized);
237    }
238
239    #[rstest]
240    fn test_value_with_one_input(mut efficiency_ratio_10: EfficiencyRatio) {
241        efficiency_ratio_10.update_raw(1.0);
242        assert_eq!(efficiency_ratio_10.value, 0.0);
243    }
244
245    #[rstest]
246    fn test_value_with_efficient_higher_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
247        let mut initial_price = 1.0;
248        for _ in 1..=10 {
249            initial_price += 0.0001;
250            efficiency_ratio_10.update_raw(initial_price);
251        }
252        assert_eq!(efficiency_ratio_10.value, 1.0);
253    }
254
255    #[rstest]
256    fn test_value_with_efficient_lower_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
257        let mut initial_price = 1.0;
258        for _ in 1..=10 {
259            initial_price -= 0.0001;
260            efficiency_ratio_10.update_raw(initial_price);
261        }
262        assert_eq!(efficiency_ratio_10.value, 1.0);
263    }
264
265    #[rstest]
266    fn test_value_with_oscillating_inputs_returns_zero(mut efficiency_ratio_10: EfficiencyRatio) {
267        efficiency_ratio_10.update_raw(1.00000);
268        efficiency_ratio_10.update_raw(1.00010);
269        efficiency_ratio_10.update_raw(1.00000);
270        efficiency_ratio_10.update_raw(0.99990);
271        efficiency_ratio_10.update_raw(1.00000);
272        assert_eq!(efficiency_ratio_10.value, 0.0);
273    }
274
275    #[rstest]
276    fn test_value_with_half_oscillating(mut efficiency_ratio_10: EfficiencyRatio) {
277        efficiency_ratio_10.update_raw(1.00000);
278        efficiency_ratio_10.update_raw(1.00020);
279        efficiency_ratio_10.update_raw(1.00010);
280        efficiency_ratio_10.update_raw(1.00030);
281        efficiency_ratio_10.update_raw(1.00020);
282        assert_approx_equal(efficiency_ratio_10.value, 0.333333333333);
283    }
284
285    #[rstest]
286    fn test_value_with_noisy_inputs(mut efficiency_ratio_10: EfficiencyRatio) {
287        efficiency_ratio_10.update_raw(1.00000);
288        efficiency_ratio_10.update_raw(1.00010);
289        efficiency_ratio_10.update_raw(1.00008);
290        efficiency_ratio_10.update_raw(1.00007);
291        efficiency_ratio_10.update_raw(1.00012);
292        efficiency_ratio_10.update_raw(1.00005);
293        efficiency_ratio_10.update_raw(1.00015);
294        assert_approx_equal(efficiency_ratio_10.value, 0.428571428572);
295    }
296
297    #[rstest]
298    #[case([10.0, 11.0, 12.0, 13.0, 14.0, 15.0], 1.0)]
299    #[case([15.0, 14.0, 13.0, 12.0, 11.0, 10.0], 1.0)]
300    #[case([10.0, 12.0, 10.0, 12.0, 10.0, 12.0], 0.2)]
301    #[case([10.0, 11.0, 10.5, 12.0, 11.5, 13.0], 0.6)]
302    #[case([10.0, 10.0, 10.0, 10.0, 10.0, 10.0], 0.0)]
303    fn test_value_uses_period_deltas(#[case] prices: [f64; 6], #[case] expected: f64) {
304        let mut efficiency_ratio = EfficiencyRatio::new(5, None);
305
306        for price in prices {
307            efficiency_ratio.update_raw(price);
308        }
309
310        assert_approx_equal(efficiency_ratio.value, expected);
311    }
312
313    #[rstest]
314    fn test_value_bounded_after_warmup() {
315        let mut efficiency_ratio = EfficiencyRatio::new(5, None);
316
317        for price in [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 14.0] {
318            efficiency_ratio.update_raw(price);
319        }
320
321        assert_eq!(
322            efficiency_ratio.inputs,
323            vec![11.0, 12.0, 13.0, 14.0, 15.0, 14.0],
324        );
325        assert_eq!(efficiency_ratio.deltas, vec![1.0; 5]);
326        assert_approx_equal(efficiency_ratio.value, 0.6);
327    }
328
329    #[rstest]
330    fn test_update_raw_reuses_reserved_window_storage() {
331        let mut efficiency_ratio = EfficiencyRatio::new(5, None);
332        let inputs_capacity = efficiency_ratio.inputs.capacity();
333        let deltas_capacity = efficiency_ratio.deltas.capacity();
334
335        for price in 0..1_000 {
336            efficiency_ratio.update_raw(f64::from(price));
337        }
338
339        assert_eq!(efficiency_ratio.inputs.capacity(), inputs_capacity);
340        assert_eq!(efficiency_ratio.deltas.capacity(), deltas_capacity);
341        assert_eq!(efficiency_ratio.inputs.len(), 6);
342        assert_eq!(efficiency_ratio.deltas.len(), 5);
343    }
344
345    #[rstest]
346    fn test_value_clamps_rounding_above_one() {
347        let mut efficiency_ratio = EfficiencyRatio::new(2, None);
348
349        for price in [0.0, 0.002, 101_070.264] {
350            efficiency_ratio.update_raw(price);
351        }
352
353        assert_eq!(efficiency_ratio.value, 1.0);
354    }
355
356    #[rstest]
357    fn test_value_remains_bounded_at_price_limits() {
358        let period = 63;
359        let mut efficiency_ratio = EfficiencyRatio::new(period, None);
360
361        for index in 0..512 {
362            let price = if index % 2 == 0 { PRICE_MIN } else { PRICE_MAX };
363            efficiency_ratio.update_raw(price);
364
365            assert!(efficiency_ratio.value.is_finite());
366            assert!((0.0..=1.0).contains(&efficiency_ratio.value));
367        }
368
369        assert_approx_equal(efficiency_ratio.value, 1.0 / period as f64);
370    }
371
372    proptest! {
373        #[rstest]
374        fn prop_value_matches_fixed_point_reference(
375            period in 2usize..=64,
376            prices in prop::collection::vec(-1_000_000_000i64..=1_000_000_000, 257..=512),
377        ) {
378            let mut efficiency_ratio = EfficiencyRatio::new(period, None);
379
380            for (index, raw_price) in prices.iter().copied().enumerate() {
381                efficiency_ratio.update_raw(raw_price as f64 / 1_000.0);
382
383                let expected = reference_value(&prices[..=index], period);
384                let error = (efficiency_ratio.value - expected).abs();
385
386                prop_assert!(efficiency_ratio.value.is_finite());
387                prop_assert!((0.0..=1.0).contains(&efficiency_ratio.value));
388                prop_assert!(
389                    error <= 1e-12,
390                    "expected {expected}, was {}",
391                    efficiency_ratio.value,
392                );
393                prop_assert_eq!(
394                    efficiency_ratio.inputs.len(),
395                    (index + 1).min(period + 1),
396                );
397                prop_assert_eq!(efficiency_ratio.deltas.len(), index.min(period));
398                prop_assert_eq!(efficiency_ratio.initialized, index + 1 >= period);
399            }
400        }
401    }
402
403    fn reference_value(prices: &[i64], period: usize) -> f64 {
404        let window = &prices[prices.len().saturating_sub(period + 1)..];
405        let net_change = (i128::from(window[window.len() - 1]) - i128::from(window[0])).abs();
406        let total_change = window
407            .windows(2)
408            .map(|pair| (i128::from(pair[1]) - i128::from(pair[0])).abs())
409            .sum::<i128>();
410
411        if total_change == 0 {
412            0.0
413        } else {
414            net_change as f64 / total_change as f64
415        }
416    }
417
418    #[rstest]
419    fn test_reset_clears_deltas(mut efficiency_ratio_10: EfficiencyRatio) {
420        // Regression: reset must clear the deltas buffer too, otherwise stale
421        // deltas leak into the next run's sum.
422        for price in [1.0, 3.0, 6.0, 10.0, 15.0] {
423            efficiency_ratio_10.update_raw(price);
424        }
425        efficiency_ratio_10.reset();
426        assert!(efficiency_ratio_10.deltas.is_empty());
427
428        // Fresh run: two inputs of a single clean move give a ratio of 1.
429        efficiency_ratio_10.update_raw(100.0);
430        efficiency_ratio_10.update_raw(100.5);
431        assert_eq!(efficiency_ratio_10.value, 1.0);
432    }
433
434    #[rstest]
435    fn test_reset(mut efficiency_ratio_10: EfficiencyRatio) {
436        for i in 1..=10 {
437            efficiency_ratio_10.update_raw(f64::from(i));
438        }
439        assert!(efficiency_ratio_10.initialized);
440        efficiency_ratio_10.reset();
441        assert!(!efficiency_ratio_10.initialized);
442        assert_eq!(efficiency_ratio_10.value, 0.0);
443    }
444
445    #[rstest]
446    fn test_handle_quote_tick(mut efficiency_ratio_10: EfficiencyRatio) {
447        let quote_tick1 = stub_quote("1500.0", "1502.0");
448        let quote_tick2 = stub_quote("1502.0", "1504.0");
449
450        efficiency_ratio_10.handle_quote(&quote_tick1).unwrap();
451        efficiency_ratio_10.handle_quote(&quote_tick2).unwrap();
452        assert_eq!(efficiency_ratio_10.value, 1.0);
453    }
454
455    #[rstest]
456    fn test_handle_bar(mut efficiency_ratio_10: EfficiencyRatio) {
457        let bar1 = bar_ethusdt_binance_minute_bid("1500.0");
458        let bar2 = bar_ethusdt_binance_minute_bid("1510.0");
459
460        efficiency_ratio_10.handle_bar(&bar1);
461        efficiency_ratio_10.handle_bar(&bar2);
462        assert_eq!(efficiency_ratio_10.value, 1.0);
463    }
464}