1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! # Cumulative Volume Delta (CVD) Indicator
//!
//! CVD tracks the running sum of buying volume minus selling volume.
//! Useful for identifying divergences between price and order flow.
use crate::model::Bar;
use crate::studies::{Indicator, IndicatorValue};
use egui::Color32;
/// Cumulative Volume Delta (CVD)
///
/// Shows the running total of (buy volume - sell volume).
/// Volume is estimated using bar internals when tick data unavailable.
#[derive(Clone)]
pub struct CVD {
/// Values calculated from bars
values: Vec<IndicatorValue>,
/// Line color
color: Color32,
/// Visibility flag
visible: bool,
/// CVD calculation mode
mode: CVDMode,
/// Show histogram alongside line
show_histogram: bool,
/// Reset at session start
reset_at_session: bool,
}
/// CVD calculation mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CVDMode {
/// Estimate based on bar close position within range
#[default]
ClosePosition,
/// Estimate based on body ratio
BodyRatio,
/// Use tick volume split (requires footprint data)
TickSplit,
/// Bullish candles = buy, bearish = sell
Simple,
}
impl CVD {
/// Create a new CVD indicator
pub fn new() -> Self {
Self {
values: Vec::new(),
color: Color32::from_rgb(76, 175, 80), // Green for volume/delta
visible: true,
mode: CVDMode::default(),
show_histogram: false,
reset_at_session: false,
}
}
/// Set the CVD calculation mode
pub fn with_mode(mut self, mode: CVDMode) -> Self {
self.mode = mode;
self
}
/// Show histogram
pub fn with_histogram(mut self, show: bool) -> Self {
self.show_histogram = show;
self
}
/// Set color
pub fn with_color(mut self, color: Color32) -> Self {
self.color = color;
self
}
/// Estimate delta volume for a bar
fn estimate_delta(&self, bar: &Bar) -> f64 {
let range = bar.high - bar.low;
if range <= 0.0 {
// Doji - no delta
return 0.0;
}
match self.mode {
CVDMode::ClosePosition => {
// Delta based on close position within bar range
// Close at high = 100% buy, at low = 100% sell
let close_pos = (bar.close - bar.low) / range;
let buy_pct = close_pos;
let sell_pct = 1.0 - close_pos;
bar.volume * (buy_pct - sell_pct)
}
CVDMode::BodyRatio => {
// Delta based on body size and direction
let body = (bar.close - bar.open).abs();
let body_ratio = body / range;
let direction = if bar.close >= bar.open { 1.0 } else { -1.0 };
bar.volume * body_ratio * direction
}
CVDMode::TickSplit => {
// Would need footprint data - fallback to close position
let close_pos = (bar.close - bar.low) / range;
bar.volume * (2.0 * close_pos - 1.0)
}
CVDMode::Simple => {
// Bullish = buy, bearish = sell
if bar.close >= bar.open {
bar.volume
} else {
-bar.volume
}
}
}
}
}
impl Default for CVD {
fn default() -> Self {
Self::new()
}
}
impl Indicator for CVD {
fn name(&self) -> &str {
"CVD"
}
fn desc(&self) -> &str {
"Cumulative Volume Delta - Running sum of buy/sell volume difference"
}
fn calculate(&mut self, data: &[Bar]) {
self.values.clear();
if data.is_empty() {
return;
}
let mut cumulative_delta = 0.0;
let mut last_session_date = data[0].time.date_naive();
for bar in data {
// Check for session reset
if self.reset_at_session {
let current_date = bar.time.date_naive();
if current_date != last_session_date {
cumulative_delta = 0.0;
last_session_date = current_date;
}
}
let delta = self.estimate_delta(bar);
cumulative_delta += delta;
self.values.push(IndicatorValue::Single(cumulative_delta));
}
}
fn values(&self) -> &[IndicatorValue] {
&self.values
}
fn colors(&self) -> Vec<Color32> {
vec![self.color]
}
fn set_colors(&mut self, colors: Vec<Color32>) {
if !colors.is_empty() {
self.color = colors[0];
}
}
fn is_overlay(&self) -> bool {
false // CVD is drawn in separate pane
}
fn is_visible(&self) -> bool {
self.visible
}
fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
fn clone_box(&self) -> Box<dyn Indicator> {
Box::new(self.clone())
}
fn line_names(&self) -> Vec<String> {
vec!["CVD".to_string()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_bars() -> Vec<Bar> {
let ts = Utc::now();
vec![
Bar {
time: ts,
open: 100.0,
high: 102.0,
low: 99.0,
close: 101.5, // Bullish, close near high
volume: 1000.0,
},
Bar {
time: ts,
open: 101.5,
high: 103.0,
low: 100.0,
close: 100.5, // Bearish, close near low
volume: 1000.0,
},
Bar {
time: ts,
open: 100.5,
high: 102.0,
low: 99.0,
close: 101.8, // Bullish
volume: 1500.0,
},
]
}
#[test]
fn test_cvd_calculation() {
let mut cvd = CVD::new();
let bars = create_test_bars();
cvd.calculate(&bars);
assert_eq!(cvd.values.len(), 3);
// First bar is bullish (close near high) - positive delta
if let IndicatorValue::Single(val) = cvd.values[0] {
assert!(val > 0.0, "First bar should have positive delta");
}
// After second bar (bearish), delta should decrease
if let (IndicatorValue::Single(v1), IndicatorValue::Single(v2)) =
(&cvd.values[0], &cvd.values[1])
{
assert!(v2 < v1, "Second bar should decrease cumulative delta");
}
}
#[test]
fn test_cvd_simple_mode() {
let mut cvd = CVD::new().with_mode(CVDMode::Simple);
let bars = create_test_bars();
cvd.calculate(&bars);
// Simple mode: first bar bullish = +1000, second bearish = -1000
if let IndicatorValue::Single(val) = cvd.values[1] {
// First is +1000 (bullish), second is -1000 (bearish) = 0
assert!(
val.abs() < 1.0,
"After one bullish and one bearish, delta should be near 0"
);
}
}
}