Skip to main content

nautilus_indicators/average/
rma.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_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,
27};
28
29/// Wilder 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 WilderMovingAverage {
41    pub period: usize,
42    pub price_type: PriceType,
43    pub alpha: f64,
44    pub value: f64,
45    pub count: usize,
46    pub initialized: bool,
47    has_inputs: bool,
48    seed_sum: f64,
49}
50
51impl Display for WilderMovingAverage {
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 WilderMovingAverage {
58    fn name(&self) -> String {
59        stringify!(WilderMovingAverage).to_string()
60    }
61
62    fn has_inputs(&self) -> bool {
63        self.has_inputs
64    }
65    fn initialized(&self) -> bool {
66        self.initialized
67    }
68
69    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
70        self.update_raw(quote.extract_price(self.price_type)?.into());
71        Ok(())
72    }
73
74    fn handle_trade(&mut self, t: &TradeTick) {
75        self.update_raw((&t.price).into());
76    }
77
78    fn handle_bar(&mut self, b: &Bar) {
79        self.update_raw((&b.close).into());
80    }
81
82    fn reset(&mut self) {
83        self.value = 0.0;
84        self.count = 0;
85        self.has_inputs = false;
86        self.initialized = false;
87        self.seed_sum = 0.0;
88    }
89}
90
91impl WilderMovingAverage {
92    /// Creates a new [`WilderMovingAverage`] instance.
93    ///
94    /// # Panics
95    ///
96    /// Panics if `period` is zero or exceeds 16,777,216.
97    #[must_use]
98    pub fn new(period: usize, price_type: Option<PriceType>) -> Self {
99        Self::new_checked(period, price_type).expect(FAILED)
100    }
101
102    /// Creates a Wilder moving average with a validated period.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if `period` is zero or exceeds 16,777,216.
107    pub fn new_checked(period: usize, price_type: Option<PriceType>) -> anyhow::Result<Self> {
108        check_predicate_true(
109            period > 0,
110            &format!("WilderMovingAverage: period must be > 0 (received {period})"),
111        )?;
112        check_predicate_true(
113            period <= MAX_PERIOD,
114            &format!("period cannot exceed {MAX_PERIOD}"),
115        )?;
116        Ok(Self {
117            period,
118            price_type: price_type.unwrap_or(PriceType::Last),
119            alpha: 1.0 / period as f64,
120            value: 0.0,
121            count: 0,
122            initialized: false,
123            has_inputs: false,
124            seed_sum: 0.0,
125        })
126    }
127}
128
129impl MovingAverage for WilderMovingAverage {
130    fn value(&self) -> f64 {
131        self.value
132    }
133
134    fn count(&self) -> usize {
135        self.count
136    }
137
138    fn update_raw(&mut self, price: f64) {
139        if !price.is_finite() {
140            return;
141        }
142
143        self.has_inputs = true;
144        self.count += 1;
145        if self.count < self.period {
146            self.seed_sum += price;
147            return;
148        }
149
150        if self.count == self.period {
151            self.seed_sum += price;
152            self.value = self.seed_sum / self.period as f64;
153            self.initialized = true;
154            return;
155        }
156
157        self.value = (self.value * (self.period as f64 - 1.0) + price) / self.period as f64;
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use nautilus_model::{
164        data::{Bar, QuoteTick, TradeTick},
165        enums::PriceType,
166    };
167    use rstest::rstest;
168
169    use super::MAX_PERIOD;
170    use crate::{
171        average::rma::WilderMovingAverage,
172        indicator::{Indicator, MovingAverage},
173        stubs::*,
174    };
175
176    #[rstest]
177    #[case(0)]
178    #[case(MAX_PERIOD + 1)]
179    #[case(usize::MAX)]
180    fn test_checked_constructor_rejects_invalid_period(#[case] period: usize) {
181        assert!(WilderMovingAverage::new_checked(period, None).is_err());
182    }
183
184    #[rstest]
185    fn test_rma_initialized(indicator_rma_10: WilderMovingAverage) {
186        let rma = indicator_rma_10;
187        let display_str = format!("{rma}");
188        assert_eq!(display_str, "WilderMovingAverage(10)");
189        assert_eq!(rma.period, 10);
190        assert_eq!(rma.price_type, PriceType::Mid);
191        assert_eq!(rma.alpha, 0.1);
192        assert!(!rma.initialized);
193    }
194
195    #[rstest]
196    #[should_panic(expected = "WilderMovingAverage: period must be > 0")]
197    fn test_new_with_zero_period_panics() {
198        let _ = WilderMovingAverage::new(0, None);
199    }
200
201    #[rstest]
202    fn test_one_value_input(indicator_rma_10: WilderMovingAverage) {
203        let mut rma = indicator_rma_10;
204        rma.update_raw(1.0);
205        assert_eq!(rma.count, 1);
206        assert_eq!(rma.value, 0.0);
207        assert!(!rma.initialized());
208    }
209
210    #[rstest]
211    fn test_rma_update_raw(indicator_rma_10: WilderMovingAverage) {
212        let mut rma = indicator_rma_10;
213        for value in 1..=10 {
214            rma.update_raw(f64::from(value));
215        }
216
217        assert!(rma.has_inputs());
218        assert!(rma.initialized());
219        assert_eq!(rma.count, 10);
220        assert_eq!(rma.value, 5.5);
221    }
222
223    #[rstest]
224    fn test_reset(indicator_rma_10: WilderMovingAverage) {
225        let mut rma = indicator_rma_10;
226        rma.update_raw(1.0);
227        assert_eq!(rma.count, 1);
228        rma.reset();
229        assert_eq!(rma.count, 0);
230        assert_eq!(rma.value, 0.0);
231        assert!(!rma.initialized);
232    }
233
234    #[rstest]
235    fn test_handle_quote_tick_single(indicator_rma_10: WilderMovingAverage, stub_quote: QuoteTick) {
236        let mut rma = indicator_rma_10;
237        rma.handle_quote(&stub_quote).unwrap();
238
239        assert!(rma.has_inputs());
240        assert_eq!(rma.count, 1);
241        assert_eq!(rma.value, 0.0);
242        assert!(!rma.initialized());
243    }
244
245    #[rstest]
246    fn test_handle_quote_tick_multi(mut indicator_rma_10: WilderMovingAverage) {
247        let tick1 = stub_quote("1500.0", "1502.0");
248        let tick2 = stub_quote("1502.0", "1504.0");
249        indicator_rma_10.handle_quote(&tick1).unwrap();
250        indicator_rma_10.handle_quote(&tick2).unwrap();
251
252        assert_eq!(indicator_rma_10.count, 2);
253        assert_eq!(indicator_rma_10.value, 0.0);
254        assert!(!indicator_rma_10.initialized());
255    }
256
257    #[rstest]
258    fn test_handle_trade_tick(indicator_rma_10: WilderMovingAverage, stub_trade: TradeTick) {
259        let mut rma = indicator_rma_10;
260        rma.handle_trade(&stub_trade);
261
262        assert!(rma.has_inputs());
263        assert_eq!(rma.count, 1);
264        assert_eq!(rma.value, 0.0);
265        assert!(!rma.initialized());
266    }
267
268    #[rstest]
269    fn handle_handle_bar(
270        mut indicator_rma_10: WilderMovingAverage,
271        bar_ethusdt_binance_minute_bid: Bar,
272    ) {
273        indicator_rma_10.handle_bar(&bar_ethusdt_binance_minute_bid);
274
275        assert!(indicator_rma_10.has_inputs);
276        assert_eq!(indicator_rma_10.count, 1);
277        assert_eq!(indicator_rma_10.value, 0.0);
278        assert!(!indicator_rma_10.initialized);
279    }
280
281    #[rstest]
282    #[should_panic(expected = "WilderMovingAverage: period must be > 0")]
283    fn invalid_period_panics() {
284        let _ = WilderMovingAverage::new(0, None);
285    }
286
287    #[rstest]
288    #[case(1.0)]
289    #[case(123.456)]
290    #[case(9_876.543_21)]
291    fn first_tick_seeding_parity(#[case] seed_price: f64) {
292        let mut rma = WilderMovingAverage::new(10, None);
293        rma.update_raw(seed_price);
294
295        assert_eq!(rma.count(), 1);
296        assert_eq!(rma.value(), 0.0);
297        assert!(!rma.initialized());
298    }
299
300    #[rstest]
301    fn numeric_parity_with_reference_series() {
302        let mut rma = WilderMovingAverage::new(10, None);
303        for price in 1_u32..=10 {
304            rma.update_raw(f64::from(price));
305        }
306
307        assert!(rma.initialized());
308        assert_eq!(rma.count(), 10);
309        assert_eq!(rma.value(), 5.5);
310    }
311
312    /// Period = 1 should act as a pure 1-tick MA (α = 1) and be initialized immediately.
313    #[rstest]
314    fn test_rma_period_one_behavior() {
315        let mut rma = WilderMovingAverage::new(1, None);
316
317        // First tick seeds and immediately initializes
318        rma.update_raw(42.0);
319        assert!(rma.initialized());
320        assert_eq!(rma.count(), 1);
321        assert!((rma.value() - 42.0).abs() < 1e-12);
322
323        // With α = 1 the next tick fully replaces the previous value
324        rma.update_raw(100.0);
325        assert_eq!(rma.count(), 2);
326        assert!((rma.value() - 100.0).abs() < 1e-12);
327    }
328
329    /// Very large period: `initialized()` must remain `false` until enough samples arrive.
330    #[rstest]
331    fn test_rma_large_period_not_initialized() {
332        let mut rma = WilderMovingAverage::new(1_000, None);
333
334        for p in 1_u32..=999 {
335            rma.update_raw(f64::from(p));
336        }
337
338        assert_eq!(rma.count(), 999);
339        assert!(!rma.initialized());
340    }
341
342    #[rstest]
343    fn test_reset_reseeds_properly() {
344        let mut rma = WilderMovingAverage::new(3, None);
345        for value in [1.0, 2.0, 3.0, 4.0] {
346            rma.update_raw(value);
347        }
348        rma.reset();
349        for value in [10.0, 20.0, 30.0] {
350            rma.update_raw(value);
351        }
352
353        assert_eq!(rma.count(), 3);
354        assert!(rma.initialized());
355        assert_eq!(rma.value(), 20.0);
356    }
357
358    #[rstest]
359    fn test_default_price_type_is_last() {
360        let rma = WilderMovingAverage::new(5, None);
361        assert_eq!(rma.price_type, PriceType::Last);
362    }
363
364    #[rstest]
365    fn test_update_with_nan_propagates() {
366        let mut rma = WilderMovingAverage::new(3, None);
367        rma.update_raw(1.0);
368        let before = (rma.value(), rma.count());
369
370        rma.update_raw(f64::NAN);
371        rma.update_raw(f64::NEG_INFINITY);
372
373        assert_eq!((rma.value(), rma.count()), before);
374        assert!(rma.has_inputs());
375    }
376}