Skip to main content

wickra_core/indicators/
cvd.rs

1//! Cumulative Volume Delta — running sum of signed trade volume.
2
3use crate::microstructure::Trade;
4use crate::traits::Indicator;
5
6/// Cumulative Volume Delta (CVD) — the running sum of [signed volume].
7///
8/// ```text
9/// CVDₜ = CVDₜ₋₁ + sizeₜ · (+1 if buy, −1 if sell)
10/// ```
11///
12/// CVD is an unbounded running total: a rising line signals net buying pressure
13/// over the session, a falling line net selling. Divergence between CVD and
14/// price is a classic absorption / exhaustion signal. Call [`reset`] at the
15/// start of each session to re-anchor the cumulative total at zero.
16///
17/// `Input = Trade`, `Output = f64`. Ready after the first trade.
18///
19/// [signed volume]: crate::SignedVolume
20/// [`reset`]: crate::Indicator::reset
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{CumulativeVolumeDelta, Indicator, Side, Trade};
26///
27/// let mut cvd = CumulativeVolumeDelta::new();
28/// assert_eq!(cvd.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), Some(5.0));
29/// assert_eq!(cvd.update(Trade::new(100.0, 2.0, Side::Sell, 1).unwrap()), Some(3.0));
30/// ```
31#[derive(Debug, Clone, Default)]
32pub struct CumulativeVolumeDelta {
33    cumulative: f64,
34    has_emitted: bool,
35}
36
37impl CumulativeVolumeDelta {
38    /// Construct a new CVD indicator with a zero running total.
39    pub const fn new() -> Self {
40        Self {
41            cumulative: 0.0,
42            has_emitted: false,
43        }
44    }
45}
46
47impl Indicator for CumulativeVolumeDelta {
48    type Input = Trade;
49    type Output = f64;
50
51    #[inline]
52    fn update(&mut self, trade: Trade) -> Option<f64> {
53        self.has_emitted = true;
54        self.cumulative += trade.size * trade.side.sign();
55        Some(self.cumulative)
56    }
57
58    fn reset(&mut self) {
59        self.cumulative = 0.0;
60        self.has_emitted = false;
61    }
62
63    #[inline]
64    fn warmup_period(&self) -> usize {
65        1
66    }
67
68    #[inline]
69    fn is_ready(&self) -> bool {
70        self.has_emitted
71    }
72
73    #[inline]
74    fn name(&self) -> &'static str {
75        "CumulativeVolumeDelta"
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::microstructure::Side;
83    use crate::traits::BatchExt;
84
85    fn trade(size: f64, side: Side, ts: i64) -> Trade {
86        Trade::new(100.0, size, side, ts).unwrap()
87    }
88
89    #[test]
90    fn accessors_and_metadata() {
91        let cvd = CumulativeVolumeDelta::new();
92        assert_eq!(cvd.name(), "CumulativeVolumeDelta");
93        assert_eq!(cvd.warmup_period(), 1);
94        assert!(!cvd.is_ready());
95    }
96
97    #[test]
98    fn accumulates_signed_volume() {
99        let mut cvd = CumulativeVolumeDelta::new();
100        assert_eq!(cvd.update(trade(5.0, Side::Buy, 0)), Some(5.0));
101        assert_eq!(cvd.update(trade(2.0, Side::Sell, 1)), Some(3.0));
102        assert_eq!(cvd.update(trade(4.0, Side::Sell, 2)), Some(-1.0));
103        assert!(cvd.is_ready());
104    }
105
106    #[test]
107    fn batch_equals_streaming() {
108        let trades: Vec<Trade> = (0..20)
109            .map(|i| {
110                let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
111                trade(1.0 + (i % 4) as f64, side, i)
112            })
113            .collect();
114        let mut a = CumulativeVolumeDelta::new();
115        let mut b = CumulativeVolumeDelta::new();
116        assert_eq!(
117            a.batch(&trades),
118            trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
119        );
120    }
121
122    #[test]
123    fn reset_re_anchors_at_zero() {
124        let mut cvd = CumulativeVolumeDelta::new();
125        cvd.update(trade(5.0, Side::Buy, 0));
126        cvd.reset();
127        assert!(!cvd.is_ready());
128        // After reset the running total starts again from zero.
129        assert_eq!(cvd.update(trade(2.0, Side::Buy, 1)), Some(2.0));
130    }
131}