kestrel_chartkit/indicator/vidya.rs
1use crate::model::Bar;
2
3use super::momentum_indicators::CmoEngine;
4use super::{Indicator, IndicatorOutput};
5
6/// Variable Index Dynamic Average: an exponential average whose smoothing constant is scaled by
7/// how directional the recent price movement is.
8///
9/// `alpha_t = 2/(ema_len + 1) * |CMO_t| / 100` and
10/// `VIDYA_t = alpha_t * close_t + (1 - alpha_t) * VIDYA_{t-1}`.
11///
12/// The Chande Momentum Oscillator comes from the existing [`CmoEngine`] over `cmo_len` and is
13/// already scaled to `-100..=100`, which is why the formula divides by 100 exactly once. In a
14/// directionless market `|CMO|` approaches zero, `alpha` with it, and the line holds its level;
15/// at `|CMO| = 100` the average reacts like a plain `Ema(ema_len)`.
16///
17/// This is the CMO-based variant. The variant driven by a ratio of standard deviations carries
18/// the same name elsewhere but is a different formula; it is not offered here.
19///
20/// The two periods are separate on purpose: `cmo_len` sets how far back the directionality is
21/// measured, `ema_len` the base smoothing that directionality scales.
22///
23/// Output: `value` in the price units of the series.
24///
25/// First output: with the first bar the CMO is defined for, i.e. after `cmo_len + 1` bars; that
26/// first value is the close of that bar (the seed). A bar whose CMO is zero leaves the line
27/// exactly where it was — the average then carries no new information, which is the point of the
28/// construction, not a gap. [`Indicator::reset`] clears the CMO state and the line, so the next
29/// series starts deterministically.
30#[derive(Debug, Clone)]
31pub struct Vidya {
32 ema_len: usize,
33 cmo: CmoEngine,
34 state: Option<f64>,
35}
36
37impl Vidya {
38 pub fn new(cmo_len: usize, ema_len: usize) -> Self {
39 Self {
40 ema_len: ema_len.max(1),
41 cmo: CmoEngine::new(cmo_len.max(1)),
42 state: None,
43 }
44 }
45
46 pub fn with_defaults() -> Self {
47 Self::new(9, 12)
48 }
49}
50
51impl Indicator for Vidya {
52 fn name(&self) -> &str {
53 "vidya"
54 }
55
56 fn warmup_period(&self) -> usize {
57 self.cmo.warmup_period()
58 }
59
60 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
61 let cmo = self.cmo.on_bar(bar)?.value;
62
63 let value = match self.state {
64 None => bar.close,
65 Some(prev) => {
66 let alpha = 2.0 / (self.ema_len as f64 + 1.0) * (cmo.abs() / 100.0);
67 alpha * bar.close + (1.0 - alpha) * prev
68 }
69 };
70 self.state = Some(value);
71
72 Some(IndicatorOutput::new(value))
73 }
74
75 fn reset(&mut self) {
76 self.cmo.reset();
77 self.state = None;
78 }
79}