Skip to main content

nautilus_indicators/average/
sma.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::{collections::VecDeque, fmt::Display};
17
18use nautilus_core::correctness::{FAILED, check_predicate_true};
19use nautilus_model::{
20    data::{Bar, QuoteTick, TradeTick},
21    enums::PriceType,
22};
23
24use crate::{
25    indicator::{Indicator, MovingAverage},
26    support::{MAX_PERIOD, SMA_RESEED_WINDOWS},
27};
28
29/// Simple moving average.
30#[repr(C)]
31#[derive(Debug)]
32#[cfg_attr(
33    feature = "python",
34    pyo3::pyclass(module = "nautilus_trader.indicators")
35)]
36#[cfg_attr(
37    feature = "python",
38    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
39)]
40pub struct SimpleMovingAverage {
41    pub period: usize,
42    pub price_type: PriceType,
43    pub value: f64,
44    sum: f64,
45    updates_since_reseed: usize,
46    pub count: usize,
47    buf: VecDeque<f64>,
48    pub initialized: bool,
49}
50
51impl Display for SimpleMovingAverage {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}({})", self.name(), self.period)
54    }
55}
56
57impl Indicator for SimpleMovingAverage {
58    fn name(&self) -> String {
59        stringify!(SimpleMovingAverage).into()
60    }
61
62    fn has_inputs(&self) -> bool {
63        self.count > 0
64    }
65
66    fn initialized(&self) -> bool {
67        self.initialized
68    }
69
70    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
71        self.process_raw(quote.extract_price(self.price_type)?.into());
72        Ok(())
73    }
74
75    fn handle_trade(&mut self, trade: &TradeTick) {
76        self.process_raw(trade.price.into());
77    }
78
79    fn handle_bar(&mut self, bar: &Bar) {
80        self.process_raw(bar.close.into());
81    }
82
83    fn reset(&mut self) {
84        self.value = 0.0;
85        self.sum = 0.0;
86        self.updates_since_reseed = 0;
87        self.count = 0;
88        self.buf.clear();
89        self.initialized = false;
90    }
91}
92
93impl MovingAverage for SimpleMovingAverage {
94    fn value(&self) -> f64 {
95        self.value
96    }
97
98    fn count(&self) -> usize {
99        self.count
100    }
101
102    fn update_raw(&mut self, value: f64) {
103        self.process_raw(value);
104    }
105}
106
107impl SimpleMovingAverage {
108    /// Creates a new [`SimpleMovingAverage`] instance.
109    ///
110    /// # Panics
111    ///
112    /// Panics if `period` is zero or exceeds the supported indicator period limit.
113    #[must_use]
114    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
115        Self::new_checked(period, price_type).expect(FAILED)
116    }
117
118    /// Creates a simple moving average with a validated period.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if `period` is zero or exceeds the supported indicator period limit.
123    pub fn new_checked(period: usize, price_type: Option<PriceType>) -> anyhow::Result<Self> {
124        check_predicate_true(period > 0, "SimpleMovingAverage: period must be > 0")?;
125        check_predicate_true(
126            period <= MAX_PERIOD,
127            &format!("SimpleMovingAverage: period {period} exceeds MAX_PERIOD ({MAX_PERIOD})"),
128        )?;
129
130        Ok(Self {
131            period,
132            price_type: price_type.unwrap_or(PriceType::Last),
133            value: 0.0,
134            sum: 0.0,
135            updates_since_reseed: 0,
136            count: 0,
137            buf: VecDeque::with_capacity(period),
138            initialized: false,
139        })
140    }
141
142    fn process_raw(&mut self, price: f64) {
143        if !price.is_finite() {
144            return;
145        }
146
147        if self.count == self.period {
148            if let Some(oldest) = self.buf.pop_front() {
149                self.sum -= oldest;
150            }
151        } else {
152            self.count += 1;
153        }
154
155        self.buf.push_back(price);
156        self.sum += price;
157        self.updates_since_reseed += 1;
158        if self.updates_since_reseed >= SMA_RESEED_WINDOWS * self.period {
159            self.sum = self.buf.iter().sum();
160            self.updates_since_reseed = 0;
161        }
162
163        self.initialized = self.count >= self.period;
164        if self.initialized {
165            self.value = self.sum / self.period as f64;
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use std::collections::VecDeque;
173
174    use nautilus_model::{
175        data::{QuoteTick, TradeTick},
176        enums::PriceType,
177    };
178    use proptest::prelude::*;
179    use rstest::rstest;
180
181    use super::MAX_PERIOD;
182    use crate::{
183        average::{sma::SimpleMovingAverage, wma::WeightedMovingAverage},
184        indicator::{Indicator, MovingAverage},
185        stubs::*,
186    };
187
188    #[rstest]
189    #[case(0)]
190    #[case(MAX_PERIOD + 1)]
191    #[case(usize::MAX)]
192    fn test_checked_constructor_rejects_invalid_period(#[case] period: usize) {
193        assert!(SimpleMovingAverage::new_checked(period, None).is_err());
194    }
195
196    #[rstest]
197    fn sma_initialized_state(indicator_sma_10: SimpleMovingAverage) {
198        let display_str = format!("{indicator_sma_10}");
199        assert_eq!(display_str, "SimpleMovingAverage(10)");
200        assert_eq!(indicator_sma_10.period, 10);
201        assert_eq!(indicator_sma_10.price_type, PriceType::Mid);
202        assert_eq!(indicator_sma_10.value, 0.0);
203        assert_eq!(indicator_sma_10.count, 0);
204        assert!(!indicator_sma_10.initialized());
205        assert!(!indicator_sma_10.has_inputs());
206    }
207
208    #[rstest]
209    fn sma_update_raw_exact_period(indicator_sma_10: SimpleMovingAverage) {
210        let mut sma = indicator_sma_10;
211        for i in 1..=10 {
212            sma.update_raw(f64::from(i));
213        }
214        assert!(sma.has_inputs());
215        assert!(sma.initialized());
216        assert_eq!(sma.count, 10);
217        assert_eq!(sma.value, 5.5);
218    }
219
220    #[rstest]
221    fn sma_reset_smoke(indicator_sma_10: SimpleMovingAverage) {
222        let mut sma = indicator_sma_10;
223        sma.update_raw(1.0);
224        assert_eq!(sma.count, 1);
225        sma.reset();
226        assert_eq!(sma.count, 0);
227        assert_eq!(sma.value, 0.0);
228        assert!(!sma.initialized());
229    }
230
231    #[rstest]
232    fn sma_handle_single_quote(indicator_sma_10: SimpleMovingAverage, stub_quote: QuoteTick) {
233        let mut sma = indicator_sma_10;
234        sma.handle_quote(&stub_quote).unwrap();
235        assert_eq!(sma.count, 1);
236        assert_eq!(sma.value, 0.0);
237        assert!(!sma.initialized());
238    }
239
240    #[rstest]
241    fn sma_handle_multiple_quotes(indicator_sma_10: SimpleMovingAverage) {
242        let mut sma = indicator_sma_10;
243        let q1 = stub_quote("1500.0", "1502.0");
244        let q2 = stub_quote("1502.0", "1504.0");
245
246        sma.handle_quote(&q1).unwrap();
247        sma.handle_quote(&q2).unwrap();
248        assert_eq!(sma.count, 2);
249        assert_eq!(sma.value, 0.0);
250        assert!(!sma.initialized());
251    }
252
253    #[rstest]
254    fn sma_handle_trade(indicator_sma_10: SimpleMovingAverage, stub_trade: TradeTick) {
255        let mut sma = indicator_sma_10;
256        sma.handle_trade(&stub_trade);
257        assert_eq!(sma.count, 1);
258        assert_eq!(sma.value, 0.0);
259        assert!(!sma.initialized());
260    }
261
262    #[rstest]
263    #[case(1)]
264    #[case(3)]
265    #[case(5)]
266    #[case(16)]
267    fn count_progression_respects_period(#[case] period: usize) {
268        let mut sma = SimpleMovingAverage::new(period, None);
269
270        for i in 0..(period * 3) {
271            sma.update_raw(i as f64);
272
273            assert!(
274                sma.count() <= period,
275                "period={period}, step={i}, count={}",
276                sma.count()
277            );
278
279            let expected = usize::min(i + 1, period);
280            assert_eq!(
281                sma.count(),
282                expected,
283                "period={period}, step={i}, expected={expected}, was={}",
284                sma.count()
285            );
286        }
287    }
288
289    #[rstest]
290    #[case(1)]
291    #[case(4)]
292    #[case(10)]
293    fn count_after_reset_is_zero(#[case] period: usize) {
294        let mut sma = SimpleMovingAverage::new(period, None);
295
296        for i in 0..(period + 2) {
297            sma.update_raw(i as f64);
298        }
299        assert_eq!(sma.count(), period, "pre-reset saturation failed");
300
301        sma.reset();
302        assert_eq!(sma.count(), 0, "count not reset to zero");
303        assert_eq!(sma.value(), 0.0, "value not reset to zero");
304        assert!(!sma.initialized(), "initialized flag not cleared");
305    }
306
307    #[rstest]
308    fn count_edge_case_period_one() {
309        let mut sma = SimpleMovingAverage::new(1, None);
310
311        sma.update_raw(10.0);
312        assert_eq!(sma.count(), 1);
313        assert_eq!(sma.value(), 10.0);
314
315        sma.update_raw(20.0);
316        assert_eq!(sma.count(), 1, "count exceeded 1 with period==1");
317        assert_eq!(sma.value(), 20.0, "value not equal to latest price");
318    }
319
320    #[rstest]
321    fn sliding_window_correctness() {
322        let mut sma = SimpleMovingAverage::new(3, None);
323
324        let prices = [1.0, 2.0, 3.0, 4.0, 5.0];
325        let expect_avg = [0.0, 0.0, 2.0, 3.0, 4.0];
326
327        for (i, &p) in prices.iter().enumerate() {
328            sma.update_raw(p);
329            assert!(
330                (sma.value() - expect_avg[i]).abs() < 1e-9,
331                "step {i}: expected {}, was {}",
332                expect_avg[i],
333                sma.value()
334            );
335        }
336    }
337
338    proptest! {
339        #[rstest]
340        fn prop_sma_matches_windowed_reference(
341            period in 1usize..=32,
342            inputs in prop::collection::vec(0i64..=1_000_000i64, 1..=96),
343        ) {
344            let mut sma = SimpleMovingAverage::new(period, None);
345            let mut equal_weight_wma = WeightedMovingAverage::with_weights(period, vec![1.0; period], None);
346
347            for (index, input) in inputs.iter().enumerate() {
348                let value = *input as f64;
349                let window_start = index.saturating_add(1).saturating_sub(period);
350                let window = &inputs[window_start..=index];
351                let expected = if window.len() == period { window.iter().sum::<i64>() as f64 / period as f64 } else { 0.0 };
352
353                sma.update_raw(value);
354                equal_weight_wma.update_raw(value);
355
356                prop_assert_eq!(sma.value(), expected);
357                prop_assert_eq!(equal_weight_wma.value(), expected);
358                prop_assert_eq!(sma.count(), window.len());
359                prop_assert_eq!(equal_weight_wma.count(), window.len());
360                prop_assert_eq!(sma.initialized(), index + 1 >= period);
361                prop_assert_eq!(equal_weight_wma.initialized(), index + 1 >= period);
362            }
363        }
364    }
365
366    #[rstest]
367    #[case(2)]
368    #[case(6)]
369    fn initialized_transitions_with_count(#[case] period: usize) {
370        let mut sma = SimpleMovingAverage::new(period, None);
371
372        for i in 0..(period - 1) {
373            sma.update_raw(i as f64);
374            assert!(
375                !sma.initialized(),
376                "initialized early at i={i} (period={period})"
377            );
378        }
379
380        sma.update_raw(42.0);
381        assert_eq!(sma.count(), period);
382        assert!(sma.initialized(), "initialized flag not set at period");
383    }
384
385    #[rstest]
386    #[should_panic(expected = "period must be > 0")]
387    fn sma_new_with_zero_period_panics() {
388        let _ = SimpleMovingAverage::new(0, None);
389    }
390
391    #[rstest]
392    fn sma_rolling_mean_exact_values() {
393        let mut sma = SimpleMovingAverage::new(3, None);
394        let inputs = [1.0, 2.0, 3.0, 4.0, 5.0];
395        let expected = [0.0, 0.0, 2.0, 3.0, 4.0];
396
397        for (&price, &exp_mean) in inputs.iter().zip(expected.iter()) {
398            sma.update_raw(price);
399            assert!(
400                (sma.value() - exp_mean).abs() < 1e-12,
401                "input={price}, expected={exp_mean}, was={}",
402                sma.value()
403            );
404        }
405    }
406
407    #[rstest]
408    fn sma_reseeds_after_a_large_value_leaves_the_window() {
409        let mut sma = SimpleMovingAverage::new(2, None);
410        sma.update_raw(1.0e16);
411        for _ in 0..31 {
412            sma.update_raw(1.0);
413        }
414        assert_eq!(sma.value, 1.0);
415        assert_eq!(sma.count, 2);
416        assert!(sma.initialized);
417    }
418
419    #[rstest]
420    fn sma_matches_reference_implementation() {
421        const PERIOD: usize = 5;
422        let mut sma = SimpleMovingAverage::new(PERIOD, None);
423        let mut window = VecDeque::new();
424
425        for step in 0..20 {
426            let price = f64::from(step) * 10.0;
427            sma.update_raw(price);
428
429            if window.len() == PERIOD {
430                window.pop_front();
431            }
432            window.push_back(price);
433
434            let ref_mean = if window.len() == PERIOD {
435                window.iter().sum::<f64>() / PERIOD as f64
436            } else {
437                0.0
438            };
439            assert!(
440                (sma.value() - ref_mean).abs() < 1e-12,
441                "step={step}, expected={ref_mean}, was={}",
442                sma.value()
443            );
444        }
445    }
446
447    #[rstest]
448    #[case(f64::NAN)]
449    #[case(f64::INFINITY)]
450    #[case(f64::NEG_INFINITY)]
451    fn sma_ignores_bad_floats(#[case] bad: f64) {
452        let mut sma = SimpleMovingAverage::new(3, None);
453        sma.update_raw(1.0);
454        sma.update_raw(bad);
455        sma.update_raw(3.0);
456        assert_eq!(sma.count(), 2);
457        assert_eq!(sma.value(), 0.0);
458        assert!(!sma.initialized());
459        sma.update_raw(5.0);
460        assert_eq!(sma.value(), 3.0);
461        assert!(sma.initialized());
462    }
463
464    #[rstest]
465    fn deque_and_count_always_match() {
466        const PERIOD: usize = 8;
467        let mut sma = SimpleMovingAverage::new(PERIOD, None);
468        for i in 0..50 {
469            sma.update_raw(f64::from(i));
470            assert!(
471                sma.buf.len() == sma.count,
472                "buf.len() != count at step {i}: {} != {}",
473                sma.buf.len(),
474                sma.count
475            );
476        }
477    }
478
479    #[rstest]
480    fn sma_multiple_resets() {
481        let mut sma = SimpleMovingAverage::new(4, None);
482
483        for cycle in 0..5 {
484            for x in 0..4 {
485                sma.update_raw(f64::from(x));
486            }
487            assert!(sma.initialized(), "cycle {cycle}: not initialized");
488            sma.reset();
489            assert_eq!(sma.count(), 0);
490            assert_eq!(sma.value(), 0.0);
491            assert!(!sma.initialized());
492        }
493    }
494
495    #[rstest]
496    fn sma_buffer_never_exceeds_capacity() {
497        const PERIOD: usize = 2_048;
498        let mut sma = super::SimpleMovingAverage::new(PERIOD, None);
499
500        for i in 0..(PERIOD * 2) {
501            sma.update_raw(i as f64);
502
503            assert!(
504                sma.buf.len() <= PERIOD,
505                "step {i}: buf.len()={}, exceeds PERIOD={PERIOD}",
506                sma.buf.len(),
507            );
508        }
509        assert!(
510            sma.buf.len() == PERIOD,
511            "buffer not reported as full after saturation"
512        );
513        assert_eq!(
514            sma.count(),
515            PERIOD,
516            "count diverged from logical window length"
517        );
518    }
519
520    #[rstest]
521    fn sma_deque_eviction_order() {
522        let mut sma = super::SimpleMovingAverage::new(3, None);
523
524        sma.update_raw(1.0);
525        sma.update_raw(2.0);
526        sma.update_raw(3.0);
527        sma.update_raw(4.0);
528
529        assert_eq!(sma.buf.front().copied(), Some(2.0), "oldest element wrong");
530        assert_eq!(sma.buf.back().copied(), Some(4.0), "newest element wrong");
531
532        assert!(
533            (sma.value() - 3.0).abs() < 1e-12,
534            "unexpected mean after eviction: {}",
535            sma.value()
536        );
537    }
538
539    #[rstest]
540    fn sma_sum_consistent_with_buffer() {
541        const PERIOD: usize = 7;
542        let mut sma = super::SimpleMovingAverage::new(PERIOD, None);
543
544        for i in 0..40 {
545            sma.update_raw(f64::from(i));
546
547            let deque_sum: f64 = sma.buf.iter().copied().sum();
548            assert!(
549                (sma.sum - deque_sum).abs() < 1e-12,
550                "step {i}: internal sum={} differs from buf sum={}",
551                sma.sum,
552                deque_sum
553            );
554        }
555    }
556
557    #[rstest]
558    fn test_periodic_reseed_preserves_chronological_additions() {
559        let mut sma = SimpleMovingAverage::new(3, None);
560
561        for _ in 0..45 {
562            sma.update_raw(0.0);
563        }
564        sma.update_raw(1e308);
565        sma.update_raw(-1e308);
566        sma.update_raw(1e-100);
567
568        assert_eq!(sma.value(), 1e-100 / 3.0);
569    }
570}