Skip to main content

nautilus_indicators/average/
vidya.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::{
24    average::MovingAverageType,
25    indicator::{Indicator, MovingAverage},
26    momentum::cmo::ChandeMomentumOscillator,
27};
28
29#[repr(C)]
30#[derive(Debug)]
31#[cfg_attr(
32    feature = "python",
33    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.indicators", unsendable)
34)]
35#[cfg_attr(
36    feature = "python",
37    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
38)]
39pub struct VariableIndexDynamicAverage {
40    pub period: usize,
41    pub alpha: f64,
42    pub price_type: PriceType,
43    pub value: f64,
44    pub count: usize,
45    pub initialized: bool,
46    pub cmo: ChandeMomentumOscillator,
47    pub cmo_pct: f64,
48    has_inputs: bool,
49}
50
51impl Display for VariableIndexDynamicAverage {
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 VariableIndexDynamicAverage {
58    fn name(&self) -> String {
59        stringify!(VariableIndexDynamicAverage).into()
60    }
61
62    fn has_inputs(&self) -> bool {
63        self.has_inputs
64    }
65
66    fn initialized(&self) -> bool {
67        self.initialized
68    }
69
70    fn handle_quote(&mut self, quote: &QuoteTick) {
71        self.update_raw(quote.extract_price(self.price_type).into());
72    }
73
74    fn handle_trade(&mut self, trade: &TradeTick) {
75        self.update_raw((&trade.price).into());
76    }
77
78    fn handle_bar(&mut self, bar: &Bar) {
79        self.update_raw((&bar.close).into());
80    }
81
82    fn reset(&mut self) {
83        self.value = 0.0;
84        self.count = 0;
85        self.cmo_pct = 0.0;
86        self.alpha = 2.0 / (self.period as f64 + 1.0);
87        self.has_inputs = false;
88        self.initialized = false;
89        self.cmo.reset();
90    }
91}
92
93impl VariableIndexDynamicAverage {
94    /// Creates a new [`VariableIndexDynamicAverage`] instance.
95    ///
96    /// # Panics
97    ///
98    /// Panics if `period` is not positive (> 0).
99    #[must_use]
100    pub fn new(
101        period: usize,
102        price_type: Option<PriceType>,
103        cmo_ma_type: Option<MovingAverageType>,
104    ) -> Self {
105        assert!(
106            period > 0,
107            "VariableIndexDynamicAverage: period must be > 0 (received {period})"
108        );
109
110        Self {
111            period,
112            price_type: price_type.unwrap_or(PriceType::Last),
113            value: 0.0,
114            count: 0,
115            has_inputs: false,
116            initialized: false,
117            alpha: 2.0 / (period as f64 + 1.0),
118            cmo: ChandeMomentumOscillator::new(period, cmo_ma_type),
119            cmo_pct: 0.0,
120        }
121    }
122}
123
124impl MovingAverage for VariableIndexDynamicAverage {
125    fn value(&self) -> f64 {
126        self.value
127    }
128
129    fn count(&self) -> usize {
130        self.count
131    }
132
133    fn update_raw(&mut self, price: f64) {
134        self.cmo.update_raw(price);
135        self.cmo_pct = (self.cmo.value / 100.0).abs();
136
137        if self.initialized {
138            self.value = (self.alpha * self.cmo_pct)
139                .mul_add(price, self.alpha.mul_add(-self.cmo_pct, 1.0) * self.value);
140        }
141
142        if !self.initialized && self.cmo.initialized {
143            self.initialized = true;
144        }
145        self.has_inputs = true;
146        self.count += 1;
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use nautilus_model::data::{Bar, QuoteTick, TradeTick};
153    use rstest::rstest;
154
155    use crate::{
156        average::{sma::SimpleMovingAverage, vidya::VariableIndexDynamicAverage},
157        indicator::{Indicator, MovingAverage},
158        stubs::*,
159    };
160
161    #[rstest]
162    fn test_vidya_initialized(indicator_vidya_10: VariableIndexDynamicAverage) {
163        let display_st = format!("{indicator_vidya_10}");
164        assert_eq!(display_st, "VariableIndexDynamicAverage(10)");
165        assert_eq!(indicator_vidya_10.period, 10);
166        assert!(!indicator_vidya_10.initialized());
167        assert!(!indicator_vidya_10.has_inputs());
168    }
169
170    #[rstest]
171    #[should_panic(expected = "period must be > 0")]
172    fn sma_new_with_zero_period_panics() {
173        let _ = VariableIndexDynamicAverage::new(0, None, None);
174    }
175
176    #[rstest]
177    fn test_initialized_with_required_input(mut indicator_vidya_10: VariableIndexDynamicAverage) {
178        for i in 1..10 {
179            indicator_vidya_10.update_raw(f64::from(i));
180        }
181        assert!(!indicator_vidya_10.initialized);
182        indicator_vidya_10.update_raw(10.0);
183        assert!(indicator_vidya_10.initialized);
184    }
185
186    #[rstest]
187    fn test_value_with_one_input(mut indicator_vidya_10: VariableIndexDynamicAverage) {
188        indicator_vidya_10.update_raw(1.0);
189        assert_eq!(indicator_vidya_10.value, 0.0);
190    }
191
192    #[rstest]
193    fn test_value_with_three_inputs(mut indicator_vidya_10: VariableIndexDynamicAverage) {
194        indicator_vidya_10.update_raw(1.0);
195        indicator_vidya_10.update_raw(2.0);
196        indicator_vidya_10.update_raw(3.0);
197        assert_eq!(indicator_vidya_10.value, 0.0);
198    }
199
200    #[rstest]
201    fn test_value_with_ten_inputs(mut indicator_vidya_10: VariableIndexDynamicAverage) {
202        indicator_vidya_10.update_raw(1.00000);
203        indicator_vidya_10.update_raw(1.00010);
204        indicator_vidya_10.update_raw(1.00020);
205        indicator_vidya_10.update_raw(1.00030);
206        indicator_vidya_10.update_raw(1.00040);
207        indicator_vidya_10.update_raw(1.00050);
208        indicator_vidya_10.update_raw(1.00040);
209        indicator_vidya_10.update_raw(1.00030);
210        indicator_vidya_10.update_raw(1.00020);
211        indicator_vidya_10.update_raw(1.00010);
212        indicator_vidya_10.update_raw(1.00000);
213        assert_eq!(indicator_vidya_10.value, 0.046_813_474_863_949_87);
214    }
215
216    #[rstest]
217    fn test_handle_quote_tick(
218        mut indicator_vidya_10: VariableIndexDynamicAverage,
219        stub_quote: QuoteTick,
220    ) {
221        indicator_vidya_10.handle_quote(&stub_quote);
222        assert_eq!(indicator_vidya_10.value, 0.0);
223    }
224
225    #[rstest]
226    fn test_handle_trade_tick(
227        mut indicator_vidya_10: VariableIndexDynamicAverage,
228        stub_trade: TradeTick,
229    ) {
230        indicator_vidya_10.handle_trade(&stub_trade);
231        assert_eq!(indicator_vidya_10.value, 0.0);
232    }
233
234    #[rstest]
235    fn test_handle_bar(
236        mut indicator_vidya_10: VariableIndexDynamicAverage,
237        bar_ethusdt_binance_minute_bid: Bar,
238    ) {
239        indicator_vidya_10.handle_bar(&bar_ethusdt_binance_minute_bid);
240        assert_eq!(indicator_vidya_10.value, 0.0);
241        assert!(!indicator_vidya_10.initialized);
242    }
243
244    #[rstest]
245    fn test_reset(mut indicator_vidya_10: VariableIndexDynamicAverage) {
246        indicator_vidya_10.update_raw(1.0);
247        assert_eq!(indicator_vidya_10.count, 1);
248        assert_eq!(indicator_vidya_10.value, 0.0);
249        indicator_vidya_10.reset();
250        assert_eq!(indicator_vidya_10.value, 0.0);
251        assert_eq!(indicator_vidya_10.count, 0);
252        assert!(!indicator_vidya_10.has_inputs);
253        assert!(!indicator_vidya_10.initialized);
254    }
255
256    fn reference_ma(prices: &[f64], period: usize) -> Vec<f64> {
257        let mut buf = Vec::with_capacity(period);
258        prices
259            .iter()
260            .map(|&p| {
261                buf.push(p);
262                if buf.len() > period {
263                    buf.remove(0);
264                }
265                buf.iter().copied().sum::<f64>() / buf.len() as f64
266            })
267            .collect()
268    }
269
270    #[rstest]
271    #[case(3, vec![1.0, 2.0, 3.0, 4.0, 5.0])]
272    #[case(4, vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0])]
273    #[case(2, vec![0.1, 0.2, 0.3, 0.4])]
274    fn test_sma_exact_rolling_mean(#[case] period: usize, #[case] prices: Vec<f64>) {
275        let mut sma = SimpleMovingAverage::new(period, None);
276        let expected = reference_ma(&prices, period);
277
278        for (ix, (&price, &exp)) in prices.iter().zip(expected.iter()).enumerate() {
279            sma.update_raw(price);
280            assert_eq!(sma.count(), std::cmp::min(ix + 1, period));
281
282            let actual = sma.value();
283            assert!(
284                (actual - exp).abs() < 1e-12,
285                "tick {ix}: expected {exp}, was {actual}"
286            );
287        }
288    }
289
290    #[rstest]
291    fn test_sma_matches_reference_series() {
292        const PERIOD: usize = 5;
293
294        let prices: Vec<f64> = (1u32..=15)
295            .map(|n| f64::from(n * (n + 1) / 2) * 0.37)
296            .collect();
297
298        let reference = reference_ma(&prices, PERIOD);
299
300        let mut sma = SimpleMovingAverage::new(PERIOD, None);
301
302        for (ix, (&price, &exp)) in prices.iter().zip(reference.iter()).enumerate() {
303            sma.update_raw(price);
304
305            let actual = sma.value();
306            assert!(
307                (actual - exp).abs() < 1e-12,
308                "tick {ix}: expected {exp}, was {actual}"
309            );
310        }
311    }
312
313    #[rstest]
314    fn test_vidya_alpha_bounds() {
315        let vidya_min = VariableIndexDynamicAverage::new(1, None, None);
316        assert_eq!(vidya_min.alpha, 1.0);
317
318        let vidya_large = VariableIndexDynamicAverage::new(1_000, None, None);
319        assert!(vidya_large.alpha > 0.0 && vidya_large.alpha < 0.01);
320    }
321
322    #[rstest]
323    fn test_vidya_value_constant_when_cmo_zero() {
324        let mut vidya = VariableIndexDynamicAverage::new(3, None, None);
325
326        for _ in 0..10 {
327            vidya.update_raw(100.0);
328        }
329
330        let baseline = vidya.value;
331        for _ in 0..5 {
332            vidya.update_raw(100.0);
333            assert!((vidya.value - baseline).abs() < 1e-12);
334        }
335    }
336
337    #[rstest]
338    fn test_vidya_handles_negative_prices() {
339        let mut vidya = VariableIndexDynamicAverage::new(5, None, None);
340        let negative_prices = [-1.0, -1.2, -0.8, -1.5, -1.3, -1.1];
341
342        for p in negative_prices {
343            vidya.update_raw(p);
344            assert!(vidya.value.is_finite());
345            assert!((0.0..=1.0).contains(&vidya.cmo_pct));
346        }
347
348        assert!(vidya.value < 0.0);
349    }
350}