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    fn update(&mut self, section: CrossSection) -> Option<f64> {
63        let net = section.advancing_volume() - section.declining_volume();
64        let total = section.total_volume().max(f64::MIN_POSITIVE);
65        self.index += net / total;
66        self.has_emitted = true;
67        Some(self.index)
68    }
69
70    fn reset(&mut self) {
71        self.index = 0.0;
72        self.has_emitted = false;
73    }
74
75    fn warmup_period(&self) -> usize {
76        1
77    }
78
79    fn is_ready(&self) -> bool {
80        self.has_emitted
81    }
82
83    fn name(&self) -> &'static str {
84        "CumulativeVolumeIndex"
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::cross_section::Member;
92    use crate::traits::BatchExt;
93
94    fn tick(items: &[(f64, f64)]) -> CrossSection {
95        CrossSection::new(
96            items
97                .iter()
98                .map(|&(change, volume)| Member::new(change, volume, false, false))
99                .collect(),
100            0,
101        )
102        .unwrap()
103    }
104
105    #[test]
106    fn accessors_and_metadata() {
107        let cvi = CumulativeVolumeIndex::new();
108        assert_eq!(cvi.name(), "CumulativeVolumeIndex");
109        assert_eq!(cvi.warmup_period(), 1);
110        assert!(!cvi.is_ready());
111    }
112
113    #[test]
114    fn first_tick_emits_normalised_net() {
115        let mut cvi = CumulativeVolumeIndex::new();
116        assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
117        assert!(cvi.is_ready());
118    }
119
120    #[test]
121    fn index_accumulates_normalised_shares() {
122        let mut cvi = CumulativeVolumeIndex::new();
123        assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
124        // adv 60, dec 60, total 120 -> net 0 -> index unchanged.
125        assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5));
126    }
127
128    #[test]
129    fn zero_total_volume_leaves_index_unchanged() {
130        let mut cvi = CumulativeVolumeIndex::new();
131        cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
132        // A tick with no volume at all: net 0 / floored divisor -> 0 increment.
133        assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5));
134    }
135
136    #[test]
137    fn reset_clears_state() {
138        let mut cvi = CumulativeVolumeIndex::new();
139        cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
140        assert!(cvi.is_ready());
141        cvi.reset();
142        assert!(!cvi.is_ready());
143        assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0));
144    }
145
146    #[test]
147    fn batch_equals_streaming() {
148        let sections = vec![
149            tick(&[(1.0, 150.0), (-1.0, 50.0)]),
150            tick(&[(1.0, 60.0), (-1.0, 60.0)]),
151            tick(&[(0.0, 0.0)]),
152        ];
153        let mut a = CumulativeVolumeIndex::new();
154        let mut b = CumulativeVolumeIndex::new();
155        assert_eq!(
156            a.batch(&sections),
157            sections
158                .iter()
159                .map(|s| b.update(s.clone()))
160                .collect::<Vec<_>>()
161        );
162    }
163}