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