Skip to main content

egui_charts/studies/builtin/
cvd.rs

1//! # Cumulative Volume Delta (CVD) Indicator
2//!
3//! CVD is the running sum of each bar's volume delta, buying minus selling
4//! volume. Bars do not say which trades were buys, so the delta is estimated
5//! from the bar's shape; see [`CVDMode`].
6
7use crate::model::Bar;
8use crate::studies::palette;
9use crate::studies::stepper::Stepper;
10use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
11use egui::Color32;
12
13/// Cumulative Volume Delta (CVD)
14///
15/// Shows the running total of (buy volume - sell volume), with each bar's split
16/// estimated from its open, high, low and close.
17#[derive(Clone)]
18pub struct CVD {
19    /// Values calculated from bars
20    values: IndicatorValues,
21    stepper: Stepper<f64>,
22    /// Line color
23    color: Color32,
24    /// Visibility flag
25    visible: bool,
26    /// CVD calculation mode
27    mode: CVDMode,
28}
29
30/// How [`CVD`] estimates a bar's delta from its prices
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum CVDMode {
33    /// Estimate based on bar close position within range
34    #[default]
35    ClosePosition,
36    /// Estimate based on body ratio
37    BodyRatio,
38    /// Bullish candles = buy, bearish = sell
39    Simple,
40}
41
42impl CVD {
43    /// Create a new CVD indicator
44    pub fn new() -> Self {
45        Self {
46            values: IndicatorValues::new(),
47            stepper: Stepper::default(),
48            color: palette::TEAL, // Volume/delta
49            visible: true,
50            mode: CVDMode::default(),
51        }
52    }
53
54    /// Set the CVD calculation mode
55    pub fn with_mode(mut self, mode: CVDMode) -> Self {
56        self.mode = mode;
57        self
58    }
59
60    /// Set color
61    pub fn with_color(mut self, color: Color32) -> Self {
62        self.color = color;
63        self
64    }
65
66    /// Estimate delta volume for a bar
67    fn estimate_delta(mode: CVDMode, bar: &Bar) -> f64 {
68        let range = bar.high - bar.low;
69        if range <= 0.0 {
70            // Doji - no delta
71            return 0.0;
72        }
73
74        match mode {
75            CVDMode::ClosePosition => {
76                // Delta based on close position within bar range
77                // Close at high = 100% buy, at low = 100% sell
78                let close_pos = (bar.close - bar.low) / range;
79                let buy_pct = close_pos;
80                let sell_pct = 1.0 - close_pos;
81                bar.volume * (buy_pct - sell_pct)
82            }
83            CVDMode::BodyRatio => {
84                // Delta based on body size and direction
85                let body = (bar.close - bar.open).abs();
86                let body_ratio = body / range;
87                let direction = if bar.close >= bar.open { 1.0 } else { -1.0 };
88                bar.volume * body_ratio * direction
89            }
90            CVDMode::Simple => {
91                // Bullish = buy, bearish = sell
92                if bar.close >= bar.open {
93                    bar.volume
94                } else {
95                    -bar.volume
96                }
97            }
98        }
99    }
100}
101
102impl Default for CVD {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Indicator for CVD {
109    crate::studies::inputs::study_inputs!(self;
110        mode: choice("Mode", [CVDMode::ClosePosition, CVDMode::BodyRatio, CVDMode::Simple])
111    ; {});
112
113    fn value_format(&self) -> crate::scales::StudyFormat {
114        crate::scales::StudyFormat::Volume
115    }
116
117    fn name(&self) -> &str {
118        "CVD"
119    }
120
121    fn desc(&self) -> &str {
122        "Cumulative Volume Delta - Running sum of buy minus sell volume, estimated from bars"
123    }
124
125    fn calculate(&mut self, data: &[Bar]) {
126        self.update(data, 0);
127    }
128
129    fn update(&mut self, data: &[Bar], from: usize) {
130        self.stepper
131            .run(data, from, &mut self.values, |state, bar| {
132                if !(self.mode == CVDMode::ClosePosition || bar.open.is_finite())
133                    || ![bar.high, bar.low, bar.close, bar.volume]
134                        .iter()
135                        .all(|x| x.is_finite())
136                {
137                    return IndicatorValue::None;
138                }
139                *state += Self::estimate_delta(self.mode, bar);
140                IndicatorValue::Single(*state)
141            });
142    }
143
144    fn is_incremental(&self) -> bool {
145        true
146    }
147
148    fn values(&self) -> &IndicatorValues {
149        &self.values
150    }
151
152    fn colors(&self) -> Vec<Color32> {
153        vec![self.color]
154    }
155
156    fn set_colors(&mut self, colors: Vec<Color32>) {
157        if !colors.is_empty() {
158            self.color = colors[0];
159        }
160    }
161
162    fn is_overlay(&self) -> bool {
163        false // CVD is drawn in separate pane
164    }
165
166    fn is_visible(&self) -> bool {
167        self.visible
168    }
169
170    fn set_visible(&mut self, visible: bool) {
171        self.visible = visible;
172    }
173
174    fn clone_box(&self) -> Box<dyn Indicator> {
175        Box::new(self.clone())
176    }
177
178    fn line_names(&self) -> Vec<String> {
179        vec!["CVD".to_string()]
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use chrono::Utc;
187
188    fn create_test_bars() -> Vec<Bar> {
189        let ts = Utc::now();
190        vec![
191            Bar {
192                time: ts,
193                open: 100.0,
194                high: 102.0,
195                low: 99.0,
196                close: 101.5, // Bullish, close near high
197                volume: 1000.0,
198                colors: None,
199                payload: None,
200            },
201            Bar {
202                time: ts,
203                open: 101.5,
204                high: 103.0,
205                low: 100.0,
206                close: 100.5, // Bearish, close near low
207                volume: 1000.0,
208                colors: None,
209                payload: None,
210            },
211            Bar {
212                time: ts,
213                open: 100.5,
214                high: 102.0,
215                low: 99.0,
216                close: 101.8, // Bullish
217                volume: 1500.0,
218                colors: None,
219                payload: None,
220            },
221        ]
222    }
223
224    #[test]
225    fn test_cvd_close_position_ignores_open() {
226        // The delta of a bar is volume * (2 * close_pos - 1) with close_pos = (close - low) / range:
227        // 1000 * 2/3, then 1000 * -2/3, then 1500 * 13/15 = 1300.
228        let mut bars = create_test_bars();
229        bars[1].open = f64::NAN;
230        let mut cvd = CVD::new();
231        cvd.calculate(&bars);
232        for (got, want) in cvd.values.to_rows().iter().zip([2000.0 / 3.0, 0.0, 1300.0]) {
233            let IndicatorValue::Single(got) = got else {
234                panic!("expected a value, got {got:?}");
235            };
236            assert!((got - want).abs() < 1e-9, "{got} != {want}");
237        }
238
239        // BodyRatio reads the open, so that bar has no value.
240        let mut body = CVD::new().with_mode(CVDMode::BodyRatio);
241        body.calculate(&bars);
242        assert!(matches!(body.values.to_rows()[1], IndicatorValue::None));
243    }
244
245    #[test]
246    fn test_cvd_simple_mode() {
247        let mut cvd = CVD::new().with_mode(CVDMode::Simple);
248        let bars = [Bar::new(Utc::now(), 100.0, 110.0, 99.0, 101.0, 1000.0)];
249
250        cvd.calculate(&bars);
251
252        // Bullish means +1000 in Simple mode, though ClosePosition gives -7000/11.
253        assert_eq!(cvd.values().value(0, 0), Some(1000.0));
254    }
255}