1use crate::data::Footprint;
4use crate::model::Bar;
5
6#[derive(Clone, Copy, Debug, Default, PartialEq)]
8pub struct TradeDeltaValue {
9 pub delta: f64,
11 pub delta_high: f64,
13 pub delta_low: f64,
15 pub open: f64,
17 pub high: f64,
19 pub low: f64,
21 pub close: f64,
23 pub rolling_volume: f64,
25 pub rolling_delta: f64,
27}
28
29pub fn trade_delta(
38 footprint: &Footprint,
39 bars: &[Bar],
40 rolling: usize,
41 reset: impl Fn(usize, &Bar) -> bool,
42) -> Vec<TradeDeltaValue> {
43 let mut values: Vec<TradeDeltaValue> = Vec::with_capacity(bars.len());
44 let mut cumulative = 0.0;
45 let mut volume_sum = 0.0;
46 let mut delta_sum = 0.0;
47 let rolling = rolling.max(1);
48 for (i, bar) in bars.iter().enumerate() {
49 if reset(i, bar) {
50 cumulative = 0.0;
51 }
52 let flow = footprint.bar_at(bar.time);
53 let delta = flow.map_or(0.0, |bar| bar.delta());
54 let volume = flow.map_or(0.0, |bar| bar.volume());
55 let (delta_low, delta_high) = flow.map_or((0.0, 0.0), |bar| (bar.min_delta, bar.max_delta));
56 if i >= rolling {
57 let old = footprint.bar_at(bars[i - rolling].time);
58 volume_sum -= old.map_or(0.0, |bar| bar.volume());
59 delta_sum -= values[i - rolling].delta;
60 }
61 volume_sum += volume;
62 delta_sum += delta;
63 let value = TradeDeltaValue {
64 delta,
65 delta_high,
66 delta_low,
67 open: cumulative,
68 high: cumulative + delta_high,
69 low: cumulative + delta_low,
70 close: cumulative + delta,
71 rolling_volume: volume_sum,
72 rolling_delta: delta_sum,
73 };
74 cumulative = value.close;
75 values.push(value);
76 }
77 values
78}
79
80#[derive(Clone)]
82pub(super) struct CvdMarkers {
83 pub divergence: bool,
84 pub swing_bars: usize,
85 pub exhaustion: bool,
86 pub exhaustion_bars: usize,
87 pub labels: Vec<crate::studies::StudyLabel>,
88 processed: usize,
89 pivots: [Option<(usize, f64, f64)>; 2],
90 runs: [usize; 2],
91 previous_delta: f64,
92}
93
94impl Default for CvdMarkers {
95 fn default() -> Self {
96 Self {
97 divergence: false,
98 swing_bars: 3,
99 exhaustion: false,
100 exhaustion_bars: 3,
101 labels: Vec::new(),
102 processed: 0,
103 pivots: [None; 2],
104 runs: [0; 2],
105 previous_delta: 0.0,
106 }
107 }
108}
109
110impl CvdMarkers {
111 pub fn enabled(&self) -> bool {
112 self.divergence || self.exhaustion
113 }
114
115 pub fn start(&self, len: usize, from: usize) -> usize {
116 if from < self.processed || len <= self.processed {
117 0
118 } else {
119 self.processed
120 }
121 }
122
123 pub fn refresh(
124 &mut self,
125 data: &[Bar],
126 from: usize,
127 rows: &[Option<[f64; 3]>],
128 values: &mut crate::studies::IndicatorValues,
129 reset: impl Fn(usize) -> bool,
130 ) {
131 use crate::studies::{LabelPlacement, StudyLabel};
132 if from == 0 {
133 self.labels.clear();
134 self.pivots = [None; 2];
135 self.runs = [0; 2];
136 self.previous_delta = 0.0;
137 }
138 if let Some(live) = data.len().checked_sub(1) {
139 for line in 3..7 {
140 values.set_value(line, live, f64::NAN);
141 }
142 }
143 let valid_price =
144 |bar: &Bar| bar.high.is_finite() && bar.low.is_finite() && bar.high >= bar.low;
145 for at in from..data.len().saturating_sub(1) {
146 for line in 3..7 {
147 values.set_value(line, at, 0.0);
148 }
149 let bar = &data[at];
150 let delta = rows[at - from].map(|r| r[0]);
151 if delta.is_none_or(|d| !d.is_finite()) || !valid_price(bar) {
152 self.runs = [0; 2];
153 } else {
154 let delta = delta.unwrap();
155 if self.exhaustion && at > 0 && valid_price(&data[at - 1]) {
156 for side in 0..2 {
157 let (new_extreme, signed) = if side == 0 {
158 (bar.low < data[at - 1].low, -delta)
159 } else {
160 (bar.high > data[at - 1].high, delta)
161 };
162 self.runs[side] = if new_extreme && signed > 0.0 {
163 if self.runs[side] > 0 && delta.abs() < self.previous_delta.abs() {
164 self.runs[side].saturating_add(1)
165 } else {
166 1
167 }
168 } else {
169 0
170 };
171 if self.runs[side] >= self.exhaustion_bars {
172 values.set_value(5 + side, at, 1.0);
173 self.labels.push(StudyLabel {
174 bar: at,
175 price: if side == 0 { bar.low } else { bar.high },
176 text: if side == 0 {
177 "Bull exhaustion"
178 } else {
179 "Bear exhaustion"
180 }
181 .to_owned(),
182 placement: if side == 0 {
183 LabelPlacement::Below
184 } else {
185 LabelPlacement::Above
186 },
187 plot: 1 + side,
188 });
189 }
190 }
191 }
192 self.previous_delta = delta;
193 }
194 let span = self.swing_bars.saturating_mul(2);
195 if self.divergence
196 && at >= span
197 && (at - span..=at).all(|i| valid_price(&data[i]))
198 && let Some(cvd) = values.value(0, at - self.swing_bars)
199 {
200 let pivot = at - self.swing_bars;
201 let p = &data[pivot];
202 for side in 0..2 {
203 let price = if side == 0 { p.low } else { p.high };
204 let is_pivot = (at - span..=at).filter(|&i| i != pivot).all(|i| {
205 if side == 0 {
206 data[i].low > price
207 } else {
208 data[i].high < price
209 }
210 });
211 if !is_pivot {
212 continue;
213 }
214 if self.pivots[side].is_some_and(|(previous, previous_price, previous_cvd)| {
215 let divergent = if side == 0 {
216 price < previous_price && cvd > previous_cvd
217 } else {
218 price > previous_price && cvd < previous_cvd
219 };
220 divergent
222 && (previous + 1..=pivot).all(|i| {
223 values.value(0, i).is_some() && valid_price(&data[i]) && !reset(i)
224 })
225 }) {
226 values.set_value(3 + side, at, 1.0);
227 let label = StudyLabel {
228 bar: pivot,
229 price,
230 text: if side == 0 {
231 "Bull divergence"
232 } else {
233 "Bear divergence"
234 }
235 .to_owned(),
236 placement: if side == 0 {
237 LabelPlacement::Below
238 } else {
239 LabelPlacement::Above
240 },
241 plot: 1 + side,
242 };
243 let insert = self.labels.partition_point(|m| m.bar <= pivot);
245 if let Some(existing) =
246 insert.checked_sub(1).and_then(|i| self.labels.get_mut(i))
247 && existing.bar == label.bar
248 && existing.price == label.price
249 {
250 existing.text.push_str(", ");
251 existing.text.push_str(&label.text);
252 } else {
253 self.labels.insert(insert, label);
254 }
255 }
256 self.pivots[side] = Some((pivot, price, cvd));
257 }
258 }
259 }
260 self.processed = data.len().saturating_sub(1);
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::model::{BarClock, OrderSide, Tick, Timeframe};
268 use chrono::{DateTime, TimeDelta, Utc};
269
270 #[test]
271 fn candles_follow_executions_and_reset_without_resetting_the_rolling_window() {
272 let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
273 let bars: Vec<_> = (0..4)
274 .map(|i| {
275 Bar::new(
276 start + TimeDelta::minutes(i),
277 100.0,
278 100.0,
279 100.0,
280 100.0,
281 999.0,
282 )
283 })
284 .collect();
285 let mut footprint = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
286 footprint.set_size_filter(2.0, 10.0).unwrap();
287 for (second, size, side) in [
288 (0, 3.0, OrderSide::Sell),
289 (1, 8.0, OrderSide::Buy),
290 (2, 2.0, OrderSide::Sell),
291 (3, 1.0, OrderSide::Buy),
292 (60, 4.0, OrderSide::Buy),
293 (180, 6.0, OrderSide::Sell),
294 ] {
295 footprint
296 .push(&Tick::new(
297 start + TimeDelta::seconds(second),
298 100.0,
299 size,
300 Some(side),
301 ))
302 .unwrap();
303 }
304 let expected = [
306 [3.0, 0.0, 5.0, -3.0, 3.0, 13.0, 3.0],
307 [4.0, 3.0, 7.0, 3.0, 7.0, 17.0, 7.0],
308 [0.0, 7.0, 7.0, 7.0, 7.0, 4.0, 4.0],
309 [-6.0, 0.0, 0.0, -6.0, -6.0, 6.0, -6.0],
310 ];
311 for (got, expected) in trade_delta(&footprint, &bars, 2, |i, _| i == 3)
312 .iter()
313 .zip(expected)
314 {
315 assert_eq!(
316 [
317 got.delta,
318 got.open,
319 got.high,
320 got.low,
321 got.close,
322 got.rolling_volume,
323 got.rolling_delta
324 ],
325 expected
326 );
327 }
328 let mut large = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
331 for (bar, side) in bars.iter().zip([OrderSide::Buy, OrderSide::Sell]) {
332 large
333 .push(&Tick::new(bar.time, 100.0, 1e308, Some(side)))
334 .unwrap();
335 }
336 let values = trade_delta(&large, &bars[..2], 1, |_, _| false);
337 assert_eq!(
338 (
339 values[1].rolling_volume,
340 values[1].rolling_delta,
341 values[1].close
342 ),
343 (1e308, -1e308, 0.0)
344 );
345 }
346}