Skip to main content

egui_charts/studies/builtin/
trade_delta.rs

1//! Exact delta candles from aggressor-side executions, independently of the bar estimator.
2
3use crate::data::Footprint;
4use crate::model::Bar;
5
6/// One bar's trade-based delta, cumulative delta candle, and rolling totals.
7#[derive(Clone, Copy, Debug, Default, PartialEq)]
8pub struct TradeDeltaValue {
9    /// Buy minus sell volume in this bar.
10    pub delta: f64,
11    /// Highest running delta of this bar's executions, starting from zero: zero or more.
12    pub delta_high: f64,
13    /// Lowest running delta of this bar's executions, starting from zero: zero or less.
14    pub delta_low: f64,
15    /// Cumulative delta before the first execution.
16    pub open: f64,
17    /// Maximum cumulative delta reached, including the open.
18    pub high: f64,
19    /// Minimum cumulative delta reached, including the open.
20    pub low: f64,
21    /// Cumulative delta after the last execution.
22    pub close: f64,
23    /// Total executed volume over the last N displayed bars, including unknown sides.
24    pub rolling_volume: f64,
25    /// Buy minus sell volume over the last N displayed bars.
26    pub rolling_delta: f64,
27}
28
29/// Computes one value per supplied bar, with zero delta for bars without executions.
30///
31/// `reset(i, bar)` marks the start of a host-defined session or period; the first bar always
32/// starts at zero. Rolling totals cover the last `rolling.max(1)` displayed bars and continue
33/// across resets. Size filtering belongs to [`Footprint::set_size_filter`], before aggregation.
34/// Bars must be chronological, one per slot of the footprint's clock. A timestamp inside
35/// the slot is accepted, as in the feed history contract.
36/// Historical coverage is exactly the supplied executions; missing trades are never estimated.
37pub 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/// Closed-bar detection state; the live bar never enters a run or confirms a pivot.
81#[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                        // A later boundary still permits confirmation of these two swings.
221                        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                        // A delayed pivot may precede exhaustion labels already emitted.
244                        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        // Bar 0 follows -3, +5, +3; the size-1 buy is filtered. Bar 2 is empty.
305        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        // A one-bar window replaces its old volume before adding the next; two finite
329        // 1e308 volumes must not overflow an intermediate two-bar sum.
330        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}