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