Skip to main content

indicators/
cvd.rs

1//! Layer 8 — Cumulative Volume Delta (OHLCV heuristic).
2//!
3//! Estimates buy/sell volume from OHLCV bars and tracks cumulative delta,
4//! slope, and price-CVD divergence.
5
6use crate::types::Candle;
7use chrono::{DateTime, NaiveDate, TimeZone, Utc};
8use std::collections::VecDeque;
9
10pub struct CVDTracker {
11    slope_bars: usize,
12    div_lookback: usize,
13
14    day_cvd: f64,
15    last_date: Option<NaiveDate>,
16    cvd_hist: VecDeque<f64>,
17    price_hist: VecDeque<f64>,
18
19    pub cvd: f64,
20    pub delta: f64,
21    pub cvd_slope: f64,
22    pub bullish: bool,
23    /// `+1` = bullish divergence, `-1` = bearish divergence, `0` = none.
24    pub divergence: i8,
25}
26
27impl CVDTracker {
28    pub fn new(slope_bars: usize, div_lookback: usize) -> Self {
29        let cap = (div_lookback + 10).max(50);
30        Self {
31            slope_bars,
32            div_lookback,
33            day_cvd: 0.0,
34            last_date: None,
35            cvd_hist: VecDeque::with_capacity(cap),
36            price_hist: VecDeque::with_capacity(cap),
37            cvd: 0.0,
38            delta: 0.0,
39            cvd_slope: 0.0,
40            bullish: false,
41            divergence: 0,
42        }
43    }
44
45    pub fn update(&mut self, candle: &Candle) {
46        let dt: DateTime<Utc> = Utc
47            .timestamp_millis_opt(candle.time)
48            .single()
49            .unwrap_or_else(Utc::now);
50        let date = dt.date_naive();
51
52        if Some(date) != self.last_date {
53            self.day_cvd = 0.0;
54            self.last_date = Some(date);
55        }
56
57        let bar_rng = candle.high - candle.low;
58        let buy_vol = if bar_rng > 0.0 {
59            candle.volume * (candle.close - candle.low) / bar_rng
60        } else {
61            candle.volume * 0.5
62        };
63        self.delta = buy_vol - (candle.volume - buy_vol);
64        self.day_cvd += self.delta;
65        self.cvd = self.day_cvd;
66
67        let cap = self.cvd_hist.capacity();
68        if self.cvd_hist.len() == cap {
69            self.cvd_hist.pop_front();
70        }
71        if self.price_hist.len() == cap {
72            self.price_hist.pop_front();
73        }
74        self.cvd_hist.push_back(self.cvd);
75        self.price_hist.push_back(candle.close);
76
77        if self.cvd_hist.len() >= self.slope_bars {
78            let arr: Vec<f64> = self.cvd_hist.iter().copied().collect();
79            self.cvd_slope = arr[arr.len() - 1] - arr[arr.len() - self.slope_bars];
80        }
81        self.bullish = self.cvd_slope > 0.0;
82        self.divergence = self.check_divergence();
83    }
84
85    fn check_divergence(&self) -> i8 {
86        let n = self.cvd_hist.len().min(self.div_lookback);
87        if n < 10 {
88            return 0;
89        }
90        let prices: Vec<f64> = self.price_hist.iter().rev().take(n).copied().collect();
91        let cvds: Vec<f64> = self.cvd_hist.iter().rev().take(n).copied().collect();
92
93        let last_p = prices[0];
94        let last_c = cvds[0];
95
96        // Bullish divergence: price at new low but CVD is not
97        let min_p = prices[1..].iter().copied().fold(f64::INFINITY, f64::min);
98        let min_c = cvds[1..].iter().copied().fold(f64::INFINITY, f64::min);
99        if last_p < min_p && last_c > min_c {
100            return 1;
101        }
102
103        // Bearish divergence: price at new high but CVD is not
104        let max_p = prices[1..]
105            .iter()
106            .copied()
107            .fold(f64::NEG_INFINITY, f64::max);
108        let max_c = cvds[1..].iter().copied().fold(f64::NEG_INFINITY, f64::max);
109        if last_p > max_p && last_c < max_c {
110            return -1;
111        }
112
113        0
114    }
115}