1use 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#[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 #[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 #[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 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 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}