Skip to main content

wickra_core/indicators/
cumulative_volume_index.rs

1//! Cumulative Volume Index — running total of net advancing volume.
2
3use crate::cross_section::CrossSection;
4use crate::traits::Indicator;
5
6/// Cumulative Volume Index (CVI) — the running total of net advancing volume
7/// across a universe.
8///
9/// On each [`CrossSection`] tick the increment is `advancing volume - declining
10/// volume`, the standard definition (`StockCharts`, `MetaStock`):
11/// `CVI_t = CVI_{t-1} + (advancing volume - declining volume)`. The index is
12/// path-dependent, so only its slope and divergences against a price index carry
13/// meaning, not its absolute level. Unchanged issues contribute to neither side.
14///
15/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
16///
17/// # Example
18///
19/// ```
20/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member};
21///
22/// let mut cvi = CumulativeVolumeIndex::new();
23/// // adv vol 150, dec vol 50 -> 150 - 50 = 100.
24/// let tick = CrossSection::new(
25///     vec![
26///         Member::new(1.0, 150.0, false, false),
27///         Member::new(-1.0, 50.0, false, false),
28///     ],
29///     0,
30/// )
31/// .unwrap();
32/// assert_eq!(cvi.update(tick), Some(100.0));
33/// ```
34#[derive(Debug, Clone, Default)]
35pub struct CumulativeVolumeIndex {
36    index: f64,
37    has_emitted: bool,
38}
39
40impl CumulativeVolumeIndex {
41    /// Construct a new Cumulative Volume Index indicator.
42    #[must_use]
43    pub const fn new() -> Self {
44        Self {
45            index: 0.0,
46            has_emitted: false,
47        }
48    }
49}
50
51impl Indicator for CumulativeVolumeIndex {
52    type Input = CrossSection;
53    type Output = f64;
54
55    #[inline]
56    fn update(&mut self, section: CrossSection) -> Option<f64> {
57        let net = section.advancing_volume() - section.declining_volume();
58        self.index += net;
59        self.has_emitted = true;
60        Some(self.index)
61    }
62
63    fn reset(&mut self) {
64        self.index = 0.0;
65        self.has_emitted = false;
66    }
67
68    #[inline]
69    fn warmup_period(&self) -> usize {
70        1
71    }
72
73    #[inline]
74    fn is_ready(&self) -> bool {
75        self.has_emitted
76    }
77
78    #[inline]
79    fn name(&self) -> &'static str {
80        "CumulativeVolumeIndex"
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::cross_section::Member;
88    use crate::traits::BatchExt;
89
90    fn tick(items: &[(f64, f64)]) -> CrossSection {
91        CrossSection::new(
92            items
93                .iter()
94                .map(|&(change, volume)| Member::new(change, volume, false, false))
95                .collect(),
96            0,
97        )
98        .unwrap()
99    }
100
101    #[test]
102    fn accessors_and_metadata() {
103        let cvi = CumulativeVolumeIndex::new();
104        assert_eq!(cvi.name(), "CumulativeVolumeIndex");
105        assert_eq!(cvi.warmup_period(), 1);
106        assert!(!cvi.is_ready());
107    }
108
109    #[test]
110    fn first_tick_emits_net_volume() {
111        let mut cvi = CumulativeVolumeIndex::new();
112        assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
113        assert!(cvi.is_ready());
114    }
115
116    #[test]
117    fn index_accumulates_net_volume() {
118        let mut cvi = CumulativeVolumeIndex::new();
119        assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
120        // adv 60, dec 60 -> net 0 -> index unchanged.
121        assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
122    }
123
124    #[test]
125    fn zero_volume_leaves_index_unchanged() {
126        let mut cvi = CumulativeVolumeIndex::new();
127        cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
128        // A tick with no volume at all: net 0 -> 0 increment.
129        assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(100.0));
130    }
131
132    #[test]
133    fn reset_clears_state() {
134        let mut cvi = CumulativeVolumeIndex::new();
135        cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
136        assert!(cvi.is_ready());
137        cvi.reset();
138        assert!(!cvi.is_ready());
139        assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(100.0));
140    }
141
142    #[test]
143    fn batch_equals_streaming() {
144        let sections = vec![
145            tick(&[(1.0, 150.0), (-1.0, 50.0)]),
146            tick(&[(1.0, 60.0), (-1.0, 60.0)]),
147            tick(&[(0.0, 0.0)]),
148        ];
149        let mut a = CumulativeVolumeIndex::new();
150        let mut b = CumulativeVolumeIndex::new();
151        assert_eq!(
152            a.batch(&sections),
153            sections
154                .iter()
155                .map(|s| b.update(s.clone()))
156                .collect::<Vec<_>>()
157        );
158    }
159
160    #[test]
161    fn warmup_first_value_at_index_zero() {
162        let mut cvi = CumulativeVolumeIndex::new();
163        assert_eq!(cvi.warmup_period(), 1);
164        // warmup_period() - 1 == 0: the very first tick already emits.
165        assert!(cvi.update(tick(&[(1.0, 10.0)])).is_some());
166    }
167
168    #[test]
169    fn hand_computed_multi_member_series_ignores_unchanged() {
170        let mut cvi = CumulativeVolumeIndex::new();
171        // Tick 1: adv 100 + 20 = 120, dec 30, unchanged 500 ignored -> 120 - 30 = 90.
172        assert_eq!(
173            cvi.update(tick(&[
174                (2.0, 100.0),
175                (0.5, 20.0),
176                (-1.0, 30.0),
177                (0.0, 500.0)
178            ])),
179            Some(90.0)
180        );
181        // Tick 2: adv 10, dec 70 + 40 = 110 -> net -100 -> 90 - 100 = -10.
182        assert_eq!(
183            cvi.update(tick(&[(1.0, 10.0), (-0.5, 70.0), (-3.0, 40.0)])),
184            Some(-10.0)
185        );
186        // Tick 3: only unchanged issues -> net 0 -> -10.
187        assert_eq!(cvi.update(tick(&[(0.0, 1_000.0)])), Some(-10.0));
188        // Tick 4: adv 25, no decliners -> -10 + 25 = 15.
189        assert_eq!(cvi.update(tick(&[(1.0, 25.0)])), Some(15.0));
190    }
191
192    #[test]
193    fn reset_replays_identically_to_fresh_instance() {
194        let sections = vec![
195            tick(&[(1.0, 150.0), (-1.0, 50.0)]),
196            tick(&[(-1.0, 80.0), (0.0, 5.0)]),
197            tick(&[(1.0, 7.5), (-2.0, 2.5)]),
198        ];
199        let mut cvi = CumulativeVolumeIndex::new();
200        let first = cvi.batch(&sections);
201        cvi.reset();
202        let second = cvi.batch(&sections);
203        let fresh = CumulativeVolumeIndex::default().batch(&sections);
204        assert_eq!(first, second);
205        assert_eq!(second, fresh);
206    }
207}