Skip to main content

wickra_core/indicators/
cumulative_volume_index.rs

1//! Cumulative Volume Index — running total of volume-normalised net advancing volume.
2
3use crate::cross_section::CrossSection;
4use crate::traits::Indicator;
5
6/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net
7/// advancing volume across a universe.
8///
9/// On each [`CrossSection`] tick the increment is `(advancing volume - declining
10/// volume) / total volume`: the share of the tick's total volume that flowed,
11/// net, into advancing issues. The index accumulates this share over time. Where
12/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and
13/// so drifts with secular growth in trading activity — the CVI normalises each
14/// tick by its own total volume, so a one-share-net day in a thin market counts
15/// the same as in a heavy one. This keeps the index comparable across regimes of
16/// very different volume.
17///
18/// When a tick has zero total volume the net is necessarily zero too, so the
19/// increment is zero and the index is unchanged (the divisor is floored to the
20/// smallest positive `f64` purely to keep the division defined).
21///
22/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member};
28///
29/// let mut cvi = CumulativeVolumeIndex::new();
30/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5.
31/// let tick = CrossSection::new(
32///     vec![
33///         Member::new(1.0, 150.0, false, false),
34///         Member::new(-1.0, 50.0, false, false),
35///     ],
36///     0,
37/// )
38/// .unwrap();
39/// assert_eq!(cvi.update(tick), Some(0.5));
40/// ```
41#[derive(Debug, Clone, Default)]
42pub struct CumulativeVolumeIndex {
43    index: f64,
44    has_emitted: bool,
45}
46
47impl CumulativeVolumeIndex {
48    /// Construct a new Cumulative Volume Index indicator.
49    #[must_use]
50    pub const fn new() -> Self {
51        Self {
52            index: 0.0,
53            has_emitted: false,
54        }
55    }
56}
57
58impl Indicator for CumulativeVolumeIndex {
59    type Input = CrossSection;
60    type Output = f64;
61
62    #[inline]
63    fn update(&mut self, section: CrossSection) -> Option<f64> {
64        let net = section.advancing_volume() - section.declining_volume();
65        let total = section.total_volume().max(f64::MIN_POSITIVE);
66        self.index += net / total;
67        self.has_emitted = true;
68        Some(self.index)
69    }
70
71    fn reset(&mut self) {
72        self.index = 0.0;
73        self.has_emitted = false;
74    }
75
76    #[inline]
77    fn warmup_period(&self) -> usize {
78        1
79    }
80
81    #[inline]
82    fn is_ready(&self) -> bool {
83        self.has_emitted
84    }
85
86    #[inline]
87    fn name(&self) -> &'static str {
88        "CumulativeVolumeIndex"
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::cross_section::Member;
96    use crate::traits::BatchExt;
97
98    fn tick(items: &[(f64, f64)]) -> CrossSection {
99        CrossSection::new(
100            items
101                .iter()
102                .map(|&(change, volume)| Member::new(change, volume, false, false))
103                .collect(),
104            0,
105        )
106        .unwrap()
107    }
108
109    #[test]
110    fn accessors_and_metadata() {
111        let cvi = CumulativeVolumeIndex::new();
112        assert_eq!(cvi.name(), "CumulativeVolumeIndex");
113        assert_eq!(cvi.warmup_period(), 1);
114        assert!(!cvi.is_ready());
115    }
116
117    #[test]
118    fn first_tick_emits_normalised_net() {
119        let mut cvi = CumulativeVolumeIndex::new();
120        assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
121        assert!(cvi.is_ready());
122    }
123
124    #[test]
125    fn index_accumulates_normalised_shares() {
126        let mut cvi = CumulativeVolumeIndex::new();
127        assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
128        // adv 60, dec 60, total 120 -> net 0 -> index unchanged.
129        assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5));
130    }
131
132    #[test]
133    fn zero_total_volume_leaves_index_unchanged() {
134        let mut cvi = CumulativeVolumeIndex::new();
135        cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
136        // A tick with no volume at all: net 0 / floored divisor -> 0 increment.
137        assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5));
138    }
139
140    #[test]
141    fn reset_clears_state() {
142        let mut cvi = CumulativeVolumeIndex::new();
143        cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
144        assert!(cvi.is_ready());
145        cvi.reset();
146        assert!(!cvi.is_ready());
147        assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0));
148    }
149
150    #[test]
151    fn batch_equals_streaming() {
152        let sections = vec![
153            tick(&[(1.0, 150.0), (-1.0, 50.0)]),
154            tick(&[(1.0, 60.0), (-1.0, 60.0)]),
155            tick(&[(0.0, 0.0)]),
156        ];
157        let mut a = CumulativeVolumeIndex::new();
158        let mut b = CumulativeVolumeIndex::new();
159        assert_eq!(
160            a.batch(&sections),
161            sections
162                .iter()
163                .map(|s| b.update(s.clone()))
164                .collect::<Vec<_>>()
165        );
166    }
167}