Skip to main content

egui_charts/chart/series/
trade_delta.rs

1//! Histogram, per-bar candles and cumulative candles for execution-based delta values.
2
3use crate::chart::coords::ChartMapping;
4use crate::studies::TradeDeltaValue;
5use egui::{Color32, Painter, Rect, Stroke, pos2};
6
7/// Trade delta representation in a host-supplied pane.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum TradeDeltaStyle {
10    /// Each bar's buy volume minus sell volume, relative to zero.
11    Histogram,
12    /// Open, high, low and close of cumulative delta.
13    CumulativeCandles,
14    /// Each bar's delta as a candle that opens at zero, closes at the bar's delta and has a
15    /// wick from the lowest to the highest running delta of its executions.
16    BarCandles,
17}
18
19/// Draws exact delta values using the supplied pane's mapping and colours.
20/// `values` has the same global bar indices as the price chart. The host fits the pane's
21/// scale, including zero, to `delta` (histogram), `delta_low` to `delta_high` (bar candles)
22/// or `low` to `high` (cumulative candles). A bar whose delta is zero or more is drawn in
23/// `buy`, otherwise in `sell`.
24pub fn render_trade_delta(
25    painter: &Painter,
26    mapping: &ChartMapping,
27    values: &[TradeDeltaValue],
28    style: TradeDeltaStyle,
29    buy: Color32,
30    sell: Color32,
31) {
32    if mapping.bar_spacing <= 0.0 || !mapping.bar_spacing.is_finite() {
33        return;
34    }
35    let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
36    let first = mapping
37        .x_to_idx(mapping.rect.left())
38        .saturating_sub(1)
39        .min(values.len());
40    let end = mapping
41        .x_to_idx(mapping.rect.right())
42        .saturating_add(2)
43        .min(values.len());
44    let half_width = (mapping.bar_spacing * 0.35).max(0.5);
45    for (i, value) in values.iter().enumerate().take(end).skip(first) {
46        let x = mapping.idx_to_x(i);
47        let color = if value.delta >= 0.0 { buy } else { sell };
48        let (open, close, wick) = match style {
49            TradeDeltaStyle::Histogram => (0.0, value.delta, None),
50            TradeDeltaStyle::BarCandles => {
51                (0.0, value.delta, Some((value.delta_high, value.delta_low)))
52            }
53            TradeDeltaStyle::CumulativeCandles => {
54                (value.open, value.close, Some((value.high, value.low)))
55            }
56        };
57        let y1 = mapping.price_to_y(open);
58        let y2 = mapping.price_to_y(close);
59        if !y1.is_finite() || !y2.is_finite() {
60            continue;
61        }
62        if let Some((high, low)) = wick {
63            let (high, low) = (mapping.price_to_y(high), mapping.price_to_y(low));
64            if high.is_finite() && low.is_finite() {
65                painter.line_segment([pos2(x, high), pos2(x, low)], Stroke::new(1.0, color));
66            }
67        }
68        painter.rect_filled(
69            Rect::from_min_max(
70                pos2(x - half_width, y1.min(y2)),
71                pos2(x + half_width, y1.max(y2).max(y1.min(y2) + 1.0)),
72            ),
73            0,
74            color,
75        );
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::data::Footprint;
83    use crate::model::{Bar, BarClock, OrderSide, Tick, Timeframe};
84    use crate::scales::{PriceMap, PriceRange, PriceTransform};
85    use crate::studies::trade_delta;
86    use chrono::{DateTime, TimeDelta, Utc};
87    use egui::{Context, LayerId, Pos2, Shape, vec2};
88
89    /// Bar 0 buys 4. Bar 1 buys 5, sells 8 and buys 5: running delta 5, -3, 2 from a cumulative
90    /// 4. On a -16..16 pane 128 points tall a delta `p` is drawn at `y = 64 - 4p`.
91    #[test]
92    fn bar_candles_span_the_bar_range_from_zero_beside_histogram_and_cumulative_candles() {
93        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
94        let bars: Vec<_> = (0..2)
95            .map(|i| {
96                Bar::new(
97                    start + TimeDelta::minutes(i),
98                    100.0,
99                    100.0,
100                    100.0,
101                    100.0,
102                    0.0,
103                )
104            })
105            .collect();
106        let mut footprint = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
107        for (second, size, side) in [
108            (0, 4.0, OrderSide::Buy),
109            (60, 5.0, OrderSide::Buy),
110            (61, 8.0, OrderSide::Sell),
111            (62, 5.0, OrderSide::Buy),
112        ] {
113            footprint
114                .push(&Tick::new(
115                    start + TimeDelta::seconds(second),
116                    100.0,
117                    size,
118                    Some(side),
119                ))
120                .unwrap();
121        }
122        let values = trade_delta(&footprint, &bars, 1, |_, _| false);
123        let rect = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 128.0));
124        let price = PriceMap::new(
125            PriceTransform::default(),
126            PriceRange::new(-16.0, 16.0),
127            0.0,
128            128.0,
129            false,
130        );
131        let mapping = ChartMapping::new(rect, 20.0, 0, 1, 0.0, price);
132        for (style, wicks, bodies) in [
133            (
134                TradeDeltaStyle::Histogram,
135                vec![],
136                vec![(48.0, 64.0), (56.0, 64.0)],
137            ),
138            // Bar 1 from 0 to 2, wick from 5 to -3, although earlier bars left 4.
139            (
140                TradeDeltaStyle::BarCandles,
141                vec![(48.0, 64.0), (44.0, 76.0)],
142                vec![(48.0, 64.0), (56.0, 64.0)],
143            ),
144            // Bar 1 from 4 to 6, wick from 9 to 1.
145            (
146                TradeDeltaStyle::CumulativeCandles,
147                vec![(48.0, 64.0), (28.0, 60.0)],
148                vec![(48.0, 64.0), (40.0, 48.0)],
149            ),
150        ] {
151            let ctx = Context::default();
152            let output = ctx.run_ui(Default::default(), |_| {
153                let painter = Painter::new(ctx.clone(), LayerId::background(), rect);
154                render_trade_delta(
155                    &painter,
156                    &mapping,
157                    &values,
158                    style,
159                    Color32::GREEN,
160                    Color32::RED,
161                );
162            });
163            let (mut drawn_wicks, mut drawn_bodies) = (Vec::new(), Vec::new());
164            for shape in &output.shapes {
165                match &shape.shape {
166                    Shape::LineSegment { points, .. } => {
167                        drawn_wicks.push((points[0].y, points[1].y))
168                    }
169                    Shape::Rect(body) => drawn_bodies.push((body.rect.top(), body.rect.bottom())),
170                    _ => {}
171                }
172            }
173            output.drop_without_applying_deltas();
174            assert_eq!((drawn_wicks, drawn_bodies), (wicks, bodies), "{style:?}");
175        }
176    }
177}