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_core::correctness::{FAILED, check_predicate_true};
19use nautilus_model::{
20    data::{Bar, QuoteTick, TradeTick},
21    enums::PriceType,
22};
23
24use crate::{
25    average::MovingAverageType,
26    indicator::{Indicator, MovingAverage},
27    momentum::cmo::ChandeMomentumOscillator,
28    support::MAX_PERIOD,
29};
30
31/// Variable index dynamic average.
32#[repr(C)]
33#[derive(Debug)]
34#[cfg_attr(
35    feature = "python",
36    pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
37)]
38#[cfg_attr(
39    feature = "python",
40    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
41)]
42pub struct VariableIndexDynamicAverage {
43    pub period: usize,
44    pub cmo_period: usize,
45    pub alpha: f64,
46    pub price_type: PriceType,
47    pub value: f64,
48    pub count: usize,
49    pub initialized: bool,
50    pub cmo: ChandeMomentumOscillator,
51    pub cmo_pct: f64,
52    has_inputs: bool,
53}
54
55impl Display for VariableIndexDynamicAverage {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}({})", self.name(), self.period)
58    }
59}
60
61impl Indicator for VariableIndexDynamicAverage {
62    fn name(&self) -> String {
63        stringify!(VariableIndexDynamicAverage).into()
64    }
65
66    fn has_inputs(&self) -> bool {
67        self.has_inputs
68    }
69
70    fn initialized(&self) -> bool {
71        self.initialized
72    }
73
74    fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
75        self.update_raw(quote.extract_price(self.price_type)?.into());
76        Ok(())
77    }
78
79    fn handle_trade(&mut self, trade: &TradeTick) {
80        self.update_raw((&trade.price).into());
81    }
82
83    fn handle_bar(&mut self, bar: &Bar) {
84        self.update_raw((&bar.close).into());
85    }
86
87    fn reset(&mut self) {
88        self.value = 0.0;
89        self.count = 0;
90        self.cmo_pct = 0.0;
91        self.alpha = 2.0 / (self.period as f64 + 1.0);
92        self.has_inputs = false;
93        self.initialized = false;
94        self.cmo.reset();
95    }
96}
97
98impl VariableIndexDynamicAverage {
99    /// Creates a new [`VariableIndexDynamicAverage`] instance.
100    ///
101    /// # Panics
102    ///
103    /// Panics if `period` is not positive (> 0).
104    #[must_use]
105    pub fn new(
106        period: usize,
107        price_type: Option<PriceType>,
108        cmo_ma_type: Option<MovingAverageType>,
109    ) -> Self {
110        Self::new_checked(period, period, price_type, cmo_ma_type).expect(FAILED)
111    }
112
113    /// Creates a new [`VariableIndexDynamicAverage`] with an independent CMO period.
114    ///
115    /// # Panics
116    ///
117    /// Panics if either period is zero.
118    #[must_use]
119    pub fn new_with_cmo_period(
120        period: usize,
121        cmo_period: usize,
122        price_type: Option<PriceType>,
123        cmo_ma_type: Option<MovingAverageType>,
124    ) -> Self {
125        Self::new_checked(period, cmo_period, price_type, cmo_ma_type).expect(FAILED)
126    }
127
128    pub(crate) fn new_checked(
129        period: usize,
130        cmo_period: usize,
131        price_type: Option<PriceType>,
132        cmo_ma_type: Option<MovingAverageType>,
133    ) -> anyhow::Result<Self> {
134        check_predicate_true(
135            cmo_period <= MAX_PERIOD,
136            &format!("cmo_period cannot exceed {MAX_PERIOD}"),
137        )?;
138
139        check_predicate_true(
140            period > 0,
141            &format!("VariableIndexDynamicAverage: period must be > 0 (received {period})"),
142        )?;
143        check_predicate_true(
144            cmo_period > 0,
145            &format!("VariableIndexDynamicAverage: cmo_period must be > 0 (received {cmo_period})"),
146        )?;
147
148        Ok(Self {
149            period,
150            cmo_period,
151            price_type: price_type.unwrap_or(PriceType::Last),
152            value: 0.0,
153            count: 0,
154            has_inputs: false,
155            initialized: false,
156            alpha: 2.0 / (period as f64 + 1.0),
157            cmo: ChandeMomentumOscillator::new(cmo_period, cmo_ma_type),
158            cmo_pct: 0.0,
159        })
160    }
161}
162
163impl MovingAverage for VariableIndexDynamicAverage {
164    fn value(&self) -> f64 {
165        self.value
166    }
167
168    fn count(&self) -> usize {
169        self.count
170    }
171
172    fn update_raw(&mut self, price: f64) {
173        if !price.is_finite() {
174            return;
175        }
176
177        self.cmo.update_raw(price);
178        self.cmo_pct = (self.cmo.value / 100.0).abs();
179
180        if self.initialized {
181            self.value = (self.alpha * self.cmo_pct)
182                .mul_add(price, self.alpha.mul_add(-self.cmo_pct, 1.0) * self.value);
183        } else if self.cmo.initialized {
184            // Seed from the first price at which the CMO is ready, so the output
185            // starts at the price level instead of climbing from zero.
186            self.value = price;
187            self.initialized = true;
188        }
189        self.has_inputs = true;
190        self.count += 1;
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use nautilus_model::data::{Bar, QuoteTick, TradeTick};
197    use rstest::rstest;
198
199    use crate::{
200        average::{sma::SimpleMovingAverage, vidya::VariableIndexDynamicAverage},
201        indicator::{Indicator, MovingAverage},
202        stubs::*,
203        testing::assert_approx_equal,
204    };
205
206    #[rstest]
207    fn test_vidya_initialized(indicator_vidya_10: VariableIndexDynamicAverage) {
208        let display_st = format!("{indicator_vidya_10}");
209        assert_eq!(display_st, "VariableIndexDynamicAverage(10)");
210        assert_eq!(indicator_vidya_10.period, 10);
211        assert!(!indicator_vidya_10.initialized());
212        assert!(!indicator_vidya_10.has_inputs());
213    }
214
215    #[rstest]
216    #[should_panic(expected = "period must be > 0")]
217    fn sma_new_with_zero_period_panics() {
218        let _ = VariableIndexDynamicAverage::new(0, None, None);
219    }
220
221    #[rstest]
222    fn test_initialized_with_required_input(mut indicator_vidya_10: VariableIndexDynamicAverage) {
223        for value in 1..=10 {
224            indicator_vidya_10.update_raw(f64::from(value));
225            assert!(!indicator_vidya_10.initialized);
226        }
227        indicator_vidya_10.update_raw(11.0);
228        assert!(indicator_vidya_10.initialized);
229    }
230
231    #[rstest]
232    fn test_value_with_one_input(mut indicator_vidya_10: VariableIndexDynamicAverage) {
233        indicator_vidya_10.update_raw(1.0);
234        assert_eq!(indicator_vidya_10.value, 0.0);
235    }
236
237    #[rstest]
238    fn test_value_with_three_inputs(mut indicator_vidya_10: VariableIndexDynamicAverage) {
239        indicator_vidya_10.update_raw(1.0);
240        indicator_vidya_10.update_raw(2.0);
241        indicator_vidya_10.update_raw(3.0);
242        assert_eq!(indicator_vidya_10.value, 0.0);
243    }
244
245    #[rstest]
246    fn test_value_with_ten_inputs(mut indicator_vidya_10: VariableIndexDynamicAverage) {
247        for value in [
248            1.00000, 1.00010, 1.00020, 1.00030, 1.00040, 1.00050, 1.00040, 1.00030, 1.00020,
249            1.00010,
250        ] {
251            indicator_vidya_10.update_raw(value);
252        }
253
254        assert_eq!(indicator_vidya_10.value, 0.0);
255        assert!(!indicator_vidya_10.initialized());
256    }
257
258    #[rstest]
259    fn test_handle_quote_tick(
260        mut indicator_vidya_10: VariableIndexDynamicAverage,
261        stub_quote: QuoteTick,
262    ) {
263        indicator_vidya_10.handle_quote(&stub_quote).unwrap();
264        assert_eq!(indicator_vidya_10.value, 0.0);
265    }
266
267    #[rstest]
268    fn test_handle_trade_tick(
269        mut indicator_vidya_10: VariableIndexDynamicAverage,
270        stub_trade: TradeTick,
271    ) {
272        indicator_vidya_10.handle_trade(&stub_trade);
273        assert_eq!(indicator_vidya_10.value, 0.0);
274    }
275
276    #[rstest]
277    fn test_handle_bar(
278        mut indicator_vidya_10: VariableIndexDynamicAverage,
279        bar_ethusdt_binance_minute_bid: Bar,
280    ) {
281        indicator_vidya_10.handle_bar(&bar_ethusdt_binance_minute_bid);
282        assert_eq!(indicator_vidya_10.value, 0.0);
283        assert!(!indicator_vidya_10.initialized);
284    }
285
286    #[rstest]
287    fn test_reset(mut indicator_vidya_10: VariableIndexDynamicAverage) {
288        indicator_vidya_10.update_raw(1.0);
289        assert_eq!(indicator_vidya_10.count, 1);
290        assert_eq!(indicator_vidya_10.value, 0.0);
291        indicator_vidya_10.reset();
292        assert_eq!(indicator_vidya_10.value, 0.0);
293        assert_eq!(indicator_vidya_10.count, 0);
294        assert!(!indicator_vidya_10.has_inputs);
295        assert!(!indicator_vidya_10.initialized);
296    }
297
298    fn reference_ma(prices: &[f64], period: usize) -> Vec<f64> {
299        let mut buf = Vec::with_capacity(period);
300        prices
301            .iter()
302            .map(|&p| {
303                buf.push(p);
304                if buf.len() > period {
305                    buf.remove(0);
306                }
307
308                if buf.len() == period {
309                    buf.iter().copied().sum::<f64>() / period as f64
310                } else {
311                    0.0
312                }
313            })
314            .collect()
315    }
316
317    #[rstest]
318    #[case(3, vec![1.0, 2.0, 3.0, 4.0, 5.0])]
319    #[case(4, vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0])]
320    #[case(2, vec![0.1, 0.2, 0.3, 0.4])]
321    fn test_sma_exact_rolling_mean(#[case] period: usize, #[case] prices: Vec<f64>) {
322        let mut sma = SimpleMovingAverage::new(period, None);
323        let expected = reference_ma(&prices, period);
324
325        for (ix, (&price, &exp)) in prices.iter().zip(expected.iter()).enumerate() {
326            sma.update_raw(price);
327            assert_eq!(sma.count(), std::cmp::min(ix + 1, period));
328
329            let actual = sma.value();
330            assert!(
331                (actual - exp).abs() < 1e-12,
332                "tick {ix}: expected {exp}, was {actual}"
333            );
334        }
335    }
336
337    #[rstest]
338    fn test_sma_matches_reference_series() {
339        const PERIOD: usize = 5;
340
341        let prices: Vec<f64> = (1u32..=15)
342            .map(|n| f64::from(n * (n + 1) / 2) * 0.37)
343            .collect();
344
345        let reference = reference_ma(&prices, PERIOD);
346
347        let mut sma = SimpleMovingAverage::new(PERIOD, None);
348
349        for (ix, (&price, &exp)) in prices.iter().zip(reference.iter()).enumerate() {
350            sma.update_raw(price);
351
352            let actual = sma.value();
353            assert!(
354                (actual - exp).abs() < 1e-12,
355                "tick {ix}: expected {exp}, was {actual}"
356            );
357        }
358    }
359
360    #[rstest]
361    fn test_vidya_alpha_bounds() {
362        let vidya_min = VariableIndexDynamicAverage::new(1, None, None);
363        assert_eq!(vidya_min.alpha, 1.0);
364
365        let vidya_large = VariableIndexDynamicAverage::new(1_000, None, None);
366        assert!(vidya_large.alpha > 0.0 && vidya_large.alpha < 0.01);
367    }
368
369    #[rstest]
370    fn test_vidya_value_constant_when_cmo_zero() {
371        let mut vidya = VariableIndexDynamicAverage::new(3, None, None);
372
373        for _ in 0..10 {
374            vidya.update_raw(100.0);
375        }
376
377        let baseline = vidya.value;
378        for _ in 0..5 {
379            vidya.update_raw(100.0);
380            assert!((vidya.value - baseline).abs() < 1e-12);
381        }
382    }
383
384    #[rstest]
385    fn test_vidya_handles_negative_prices() {
386        let mut vidya = VariableIndexDynamicAverage::new(5, None, None);
387        let negative_prices = [-1.0, -1.2, -0.8, -1.5, -1.3, -1.1];
388
389        for p in negative_prices {
390            vidya.update_raw(p);
391            assert!(vidya.value.is_finite());
392            assert!((0.0..=1.0).contains(&vidya.cmo_pct));
393        }
394
395        assert!(vidya.value < 0.0);
396    }
397
398    #[rstest]
399    fn equivalent_cmo_period_constructors_match() {
400        let mut implicit = VariableIndexDynamicAverage::new(5, None, None);
401        let mut explicit = VariableIndexDynamicAverage::new_with_cmo_period(5, 5, None, None);
402
403        for value in [1.0, 2.0, 1.0, 3.0, 2.0, 4.0, 3.0, 5.0] {
404            implicit.update_raw(value);
405            explicit.update_raw(value);
406            assert_eq!(implicit.value, explicit.value);
407            assert_eq!(implicit.cmo_pct, explicit.cmo_pct);
408            assert_eq!(implicit.count, explicit.count);
409            assert_eq!(implicit.initialized, explicit.initialized);
410        }
411    }
412
413    #[rstest]
414    fn test_independent_cmo_period_matches_reference() {
415        // VIDYA(4) with CMO(2): alpha = 2/5, seeded at 11 once the CMO is ready.
416        // CMO(2) = 1/3, 1/3, 1/2 for the next three inputs.
417        let mut vidya = VariableIndexDynamicAverage::new_with_cmo_period(4, 2, None, None);
418        for value in [10.0, 12.0, 11.0] {
419            vidya.update_raw(value);
420        }
421        assert!(vidya.initialized);
422        assert_eq!(vidya.value, 11.0);
423
424        for value in [13.0, 12.0, 15.0] {
425            vidya.update_raw(value);
426        }
427        assert_approx_equal(vidya.value, 13603.0 / 1125.0);
428        assert_approx_equal(vidya.cmo_pct, 0.5);
429    }
430}