Skip to main content

nautilus_indicators/average/
ema.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 nautilus_model::{
19    data::{Bar, QuoteTick, TradeTick},
20    enums::PriceType,
21};
22
23use crate::indicator::{Indicator, MovingAverage};
24
25#[repr(C)]
26#[derive(Debug)]
27#[cfg_attr(
28    feature = "python",
29    pyo3::pyclass(module = "nautilus_trader.indicators")
30)]
31#[cfg_attr(
32    feature = "python",
33    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
34)]
35pub struct ExponentialMovingAverage {
36    pub period: usize,
37    pub price_type: PriceType,
38    pub alpha: f64,
39    pub value: f64,
40    pub count: usize,
41    pub initialized: bool,
42    has_inputs: bool,
43}
44
45impl Display for ExponentialMovingAverage {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}({})", self.name(), self.period)
48    }
49}
50
51impl Indicator for ExponentialMovingAverage {
52    fn name(&self) -> String {
53        stringify!(ExponentialMovingAverage).to_string()
54    }
55
56    fn has_inputs(&self) -> bool {
57        self.has_inputs
58    }
59
60    fn initialized(&self) -> bool {
61        self.initialized
62    }
63
64    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
65        self.update_raw(quote.extract_price(self.price_type)?.into());
66        Ok(())
67    }
68
69    fn handle_trade(&mut self, trade: &TradeTick) {
70        self.update_raw((&trade.price).into());
71    }
72
73    fn handle_bar(&mut self, bar: &Bar) {
74        self.update_raw((&bar.close).into());
75    }
76
77    fn reset(&mut self) {
78        self.value = 0.0;
79        self.count = 0;
80        self.has_inputs = false;
81        self.initialized = false;
82    }
83}
84
85impl ExponentialMovingAverage {
86    /// Creates a new [`ExponentialMovingAverage`] instance.
87    ///
88    /// # Panics
89    ///
90    /// Panics if `period` is not a positive integer (> 0).
91    #[must_use]
92    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
93        assert!(
94            period > 0,
95            "ExponentialMovingAverage::new → `period` must be positive (> 0); got {period}"
96        );
97        Self {
98            period,
99            price_type: price_type.unwrap_or(PriceType::Last),
100            alpha: 2.0 / (period as f64 + 1.0),
101            value: 0.0,
102            count: 0,
103            has_inputs: false,
104            initialized: false,
105        }
106    }
107}
108
109impl MovingAverage for ExponentialMovingAverage {
110    fn value(&self) -> f64 {
111        self.value
112    }
113
114    fn count(&self) -> usize {
115        self.count
116    }
117
118    fn update_raw(&mut self, value: f64) {
119        if !self.has_inputs {
120            self.has_inputs = true;
121            self.value = value;
122            self.count = 1;
123
124            if self.period == 1 {
125                self.initialized = true;
126            }
127            return;
128        }
129
130        self.value = self.alpha.mul_add(value, (1.0 - self.alpha) * self.value);
131        self.count += 1;
132
133        // Initialization logic
134        if !self.initialized && self.count >= self.period {
135            self.initialized = true;
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use nautilus_model::{
143        data::{Bar, QuoteTick, TradeTick},
144        enums::PriceType,
145    };
146    use rstest::rstest;
147
148    use crate::{
149        average::ema::ExponentialMovingAverage,
150        indicator::{Indicator, MovingAverage},
151        stubs::*,
152        testing::assert_approx_equal,
153    };
154
155    #[rstest]
156    fn test_ema_initialized(indicator_ema_10: ExponentialMovingAverage) {
157        let ema = indicator_ema_10;
158        let display_str = format!("{ema}");
159        assert_eq!(display_str, "ExponentialMovingAverage(10)");
160        assert_eq!(ema.period, 10);
161        assert_eq!(ema.price_type, PriceType::Mid);
162        assert_approx_equal(ema.alpha, 0.181818181818);
163        assert!(!ema.initialized);
164    }
165
166    #[rstest]
167    fn test_one_value_input(indicator_ema_10: ExponentialMovingAverage) {
168        let mut ema = indicator_ema_10;
169        ema.update_raw(1.0);
170        assert_eq!(ema.count, 1);
171        assert_eq!(ema.value, 1.0);
172    }
173
174    #[rstest]
175    fn test_ema_update_raw(indicator_ema_10: ExponentialMovingAverage) {
176        let mut ema = indicator_ema_10;
177        ema.update_raw(1.0);
178        ema.update_raw(2.0);
179        ema.update_raw(3.0);
180        ema.update_raw(4.0);
181        ema.update_raw(5.0);
182        ema.update_raw(6.0);
183        ema.update_raw(7.0);
184        ema.update_raw(8.0);
185        ema.update_raw(9.0);
186        ema.update_raw(10.0);
187
188        assert!(ema.has_inputs());
189        assert!(ema.initialized());
190        assert_eq!(ema.count, 10);
191        assert_approx_equal(ema.value, 6.23936848012);
192    }
193
194    #[rstest]
195    fn test_reset(indicator_ema_10: ExponentialMovingAverage) {
196        let mut ema = indicator_ema_10;
197        ema.update_raw(1.0);
198        assert_eq!(ema.count, 1);
199        ema.reset();
200        assert_eq!(ema.count, 0);
201        assert_eq!(ema.value, 0.0);
202        assert!(!ema.initialized);
203    }
204
205    #[rstest]
206    fn test_handle_quote_tick_single(
207        indicator_ema_10: ExponentialMovingAverage,
208        stub_quote: QuoteTick,
209    ) {
210        let mut ema = indicator_ema_10;
211        ema.handle_quote(&stub_quote).unwrap();
212        assert!(ema.has_inputs());
213        assert_eq!(ema.value, 1501.0);
214    }
215
216    #[rstest]
217    fn test_handle_quote_tick_multi(mut indicator_ema_10: ExponentialMovingAverage) {
218        let tick1 = stub_quote("1500.0", "1502.0");
219        let tick2 = stub_quote("1502.0", "1504.0");
220
221        indicator_ema_10.handle_quote(&tick1).unwrap();
222        indicator_ema_10.handle_quote(&tick2).unwrap();
223        assert_eq!(indicator_ema_10.count, 2);
224        assert_approx_equal(indicator_ema_10.value, 1501.36363636);
225    }
226
227    #[rstest]
228    fn test_handle_trade_tick(indicator_ema_10: ExponentialMovingAverage, stub_trade: TradeTick) {
229        let mut ema = indicator_ema_10;
230        ema.handle_trade(&stub_trade);
231        assert!(ema.has_inputs());
232        assert_eq!(ema.value, 1500.0);
233    }
234
235    #[rstest]
236    fn handle_handle_bar(
237        mut indicator_ema_10: ExponentialMovingAverage,
238        bar_ethusdt_binance_minute_bid: Bar,
239    ) {
240        indicator_ema_10.handle_bar(&bar_ethusdt_binance_minute_bid);
241        assert!(indicator_ema_10.has_inputs);
242        assert!(!indicator_ema_10.initialized);
243        assert_eq!(indicator_ema_10.value, 1522.0);
244    }
245
246    #[rstest]
247    fn test_period_one_behaviour() {
248        let mut ema = ExponentialMovingAverage::new(1, None);
249        assert_eq!(ema.alpha, 1.0, "α must be 1 when period = 1");
250
251        ema.update_raw(10.0);
252        assert!(ema.initialized());
253        assert_eq!(ema.value(), 10.0);
254
255        ema.update_raw(42.0);
256        assert_eq!(
257            ema.value(),
258            42.0,
259            "With α = 1, the EMA must track the latest sample exactly"
260        );
261    }
262
263    #[rstest]
264    fn test_default_price_type_is_last() {
265        let ema = ExponentialMovingAverage::new(3, None);
266        assert_eq!(
267            ema.price_type,
268            PriceType::Last,
269            "`price_type` default mismatch"
270        );
271    }
272
273    #[rstest]
274    fn test_nan_poisoning_and_reset_recovery() {
275        let mut ema = ExponentialMovingAverage::new(4, None);
276        for x in 0..3 {
277            ema.update_raw(f64::from(x));
278            assert!(ema.value().is_finite());
279        }
280
281        ema.update_raw(f64::NAN);
282        assert!(ema.value().is_nan());
283
284        ema.update_raw(123.456);
285        assert!(ema.value().is_nan());
286
287        ema.reset();
288        assert!(!ema.has_inputs());
289        ema.update_raw(7.0);
290        assert_eq!(ema.value(), 7.0);
291        assert!(ema.value().is_finite());
292    }
293
294    #[rstest]
295    fn test_reset_without_inputs_is_safe() {
296        let mut ema = ExponentialMovingAverage::new(8, None);
297        ema.reset();
298        assert!(!ema.has_inputs());
299        assert_eq!(ema.count(), 0);
300        assert!(!ema.initialized());
301    }
302
303    #[rstest]
304    fn test_has_inputs_lifecycle() {
305        let mut ema = ExponentialMovingAverage::new(5, None);
306        assert!(!ema.has_inputs());
307
308        ema.update_raw(1.23);
309        assert!(ema.has_inputs());
310
311        ema.reset();
312        assert!(!ema.has_inputs());
313    }
314
315    #[rstest]
316    fn test_subnormal_inputs_do_not_underflow() {
317        let mut ema = ExponentialMovingAverage::new(2, None);
318        let tiny = f64::MIN_POSITIVE / 2.0;
319        ema.update_raw(tiny);
320        ema.update_raw(tiny);
321        assert!(
322            ema.value() > 0.0,
323            "Underflow: EMA value collapsed to zero for sub-normal inputs"
324        );
325    }
326}