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    fn update(&mut self, trade: Trade) -> Option<f64> {
52        self.has_emitted = true;
53        self.cumulative += trade.size * trade.side.sign();
54        Some(self.cumulative)
55    }
56
57    fn reset(&mut self) {
58        self.cumulative = 0.0;
59        self.has_emitted = false;
60    }
61
62    fn warmup_period(&self) -> usize {
63        1
64    }
65
66    fn is_ready(&self) -> bool {
67        self.has_emitted
68    }
69
70    fn name(&self) -> &'static str {
71        "CumulativeVolumeDelta"
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::microstructure::Side;
79    use crate::traits::BatchExt;
80
81    fn trade(size: f64, side: Side, ts: i64) -> Trade {
82        Trade::new(100.0, size, side, ts).unwrap()
83    }
84
85    #[test]
86    fn accessors_and_metadata() {
87        let cvd = CumulativeVolumeDelta::new();
88        assert_eq!(cvd.name(), "CumulativeVolumeDelta");
89        assert_eq!(cvd.warmup_period(), 1);
90        assert!(!cvd.is_ready());
91    }
92
93    #[test]
94    fn accumulates_signed_volume() {
95        let mut cvd = CumulativeVolumeDelta::new();
96        assert_eq!(cvd.update(trade(5.0, Side::Buy, 0)), Some(5.0));
97        assert_eq!(cvd.update(trade(2.0, Side::Sell, 1)), Some(3.0));
98        assert_eq!(cvd.update(trade(4.0, Side::Sell, 2)), Some(-1.0));
99        assert!(cvd.is_ready());
100    }
101
102    #[test]
103    fn batch_equals_streaming() {
104        let trades: Vec<Trade> = (0..20)
105            .map(|i| {
106                let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
107                trade(1.0 + (i % 4) as f64, side, i)
108            })
109            .collect();
110        let mut a = CumulativeVolumeDelta::new();
111        let mut b = CumulativeVolumeDelta::new();
112        assert_eq!(
113            a.batch(&trades),
114            trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
115        );
116    }
117
118    #[test]
119    fn reset_re_anchors_at_zero() {
120        let mut cvd = CumulativeVolumeDelta::new();
121        cvd.update(trade(5.0, Side::Buy, 0));
122        cvd.reset();
123        assert!(!cvd.is_ready());
124        // After reset the running total starts again from zero.
125        assert_eq!(cvd.update(trade(2.0, Side::Buy, 1)), Some(2.0));
126    }
127}