Skip to main content

egui_charts/chart/overlays/
trade_profile.rs

1//! Trade profiles in the price pane, aligned with the bars that contributed their volume.
2
3use crate::chart::coords::ChartMapping;
4use crate::chart::overlays::{FootprintConfig, FootprintMode};
5use crate::data::NodeSensitivity;
6use crate::model::Bar;
7use crate::studies::TradeProfile;
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10use egui::{Align2, FontId, Painter, Rect, Stroke, pos2};
11
12/// Display settings of the chart's execution-based session profiles.
13/// Defaults retain the stacked session histogram, with optional annotations hidden.
14#[derive(Clone, Debug)]
15pub struct TradeProfileSettings {
16    /// Bid/ask stacks, total volume or signed delta; `DeltaVolume` also draws signed delta.
17    pub mode: FootprintMode,
18    /// Trace the total-volume POC after each complete execution bar.
19    pub developing_poc: bool,
20    /// Volume-node smoothing and prominence; `None` hides the node labels.
21    pub nodes: Option<NodeSensitivity>,
22    /// Share of total volume shaded as the contiguous value area; `None` hides it.
23    pub value_area: Option<f64>,
24    /// Draw guides at the outer edges of the value area.
25    pub value_area_guides: bool,
26}
27
28impl Default for TradeProfileSettings {
29    fn default() -> Self {
30        Self {
31            mode: FootprintMode::BidAsk,
32            developing_poc: false,
33            nodes: None,
34            value_area: Some(0.7),
35            value_area_guides: false,
36        }
37    }
38}
39
40/// Draws bid/ask stacks, total volume or signed delta profiles, their POC, value area,
41/// developing POC and volume nodes. `step` is the footprint's grouped price step and
42/// `tick` is its instrument tick size before grouping. Bands extend half a tick below
43/// their first tick and above their last tick.
44/// Histograms of partly visible sessions stay at the left edge of the viewport.
45/// Profile ranges and data remain host-owned; edit [`crate::studies::TradeProfileRanges`]
46/// in response to a session menu's merge/split actions and recalculate after trades change.
47/// A profile that is not [`complete`](TradeProfile::complete) is labelled "Partial executions".
48pub fn render_trade_profiles(
49    painter: &Painter,
50    mapping: &ChartMapping,
51    bars: &[Bar],
52    profiles: &[TradeProfile],
53    step: f64,
54    tick: f64,
55    config: &FootprintConfig,
56) {
57    render_profiles(painter, mapping, bars, profiles, step, tick, config, None);
58}
59
60/// Draws the session volume profile chart: each session fills its bar span inside a faint box,
61/// with up volume stacked before down volume, a POC across the session, and the value area shown
62/// by row opacity. Optional annotations follow the supplied settings.
63/// A session whose profile is not complete is labelled "Partial executions".
64pub(crate) fn render_session_volume_profiles(
65    painter: &Painter,
66    mapping: &ChartMapping,
67    bars: &[Bar],
68    profiles: &[TradeProfile],
69    step: f64,
70    tick: f64,
71    settings: &TradeProfileSettings,
72) {
73    let theme = painter.ctx().active_theme();
74    let guide = settings
75        .value_area_guides
76        .then_some(Stroke::new(1.0, theme.chart.axis_text_secondary));
77    let config = FootprintConfig {
78        modes: vec![settings.mode],
79        value_area: settings.value_area,
80        node_sensitivity: settings.nodes.unwrap_or_default(),
81        ask_color: DESIGN_TOKENS.semantic.brand.accent,
82        bid_color: DESIGN_TOKENS.semantic.tpo.period_6,
83        neutral_color: theme.chart.axis_text_secondary,
84        text_color: DESIGN_TOKENS.semantic.chart.bearish,
85        value_area_high: guide,
86        value_area_low: guide,
87        min_text_height: if settings.nodes.is_some() {
88            12.0
89        } else {
90            f32::INFINITY
91        },
92        ..Default::default()
93    };
94    render_profiles(
95        painter,
96        mapping,
97        bars,
98        profiles,
99        step,
100        tick,
101        &config,
102        Some(settings),
103    );
104}
105
106fn render_profiles(
107    painter: &Painter,
108    mapping: &ChartMapping,
109    bars: &[Bar],
110    profiles: &[TradeProfile],
111    step: f64,
112    tick: f64,
113    config: &FootprintConfig,
114    settings: Option<&TradeProfileSettings>,
115) {
116    let session = settings.is_some();
117    if !step.is_finite() || step <= 0.0 || !tick.is_finite() || tick <= 0.0 || bars.is_empty() {
118        return;
119    }
120    let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
121    let theme = painter.ctx().active_theme();
122    let pixel = 1.0 / painter.pixels_per_point();
123    for profile in profiles {
124        let first = bars.partition_point(|b| b.time < profile.range.start);
125        let end = bars.partition_point(|b| b.time < profile.range.end);
126        if first >= end {
127            continue;
128        }
129        let x = mapping.idx_to_x(first) - mapping.bar_spacing * 0.45;
130        if mapping.idx_to_x(end - 1) < mapping.rect.left() || x > mapping.rect.right() {
131            continue;
132        }
133        let x = x.max(mapping.rect.left());
134        let session_width = (mapping.idx_to_x(end - 1) + mapping.bar_spacing * 0.5 - x).max(1.0);
135        let width = if session {
136            session_width
137        } else {
138            ((end - first) as f32 * mapping.bar_spacing * 0.7)
139                .clamp(1.0, 160.0)
140                .min(session_width)
141        };
142        let max = profile
143            .volume
144            .levels
145            .values()
146            .map(|r| r.volume())
147            .fold(0.0, f64::max);
148        if max <= 0.0 {
149            continue;
150        }
151        if session
152            && let (Some((&low, _)), Some((&high, _))) = (
153                profile.volume.levels.first_key_value(),
154                profile.volume.levels.last_key_value(),
155            )
156        {
157            let y0 = mapping.price_to_y(low as f64 * step - tick * 0.5);
158            let y1 = mapping.price_to_y((high as f64 + 1.0) * step - tick * 0.5);
159            if y0.is_finite() && y1.is_finite() {
160                let fill = theme
161                    .chart
162                    .bg
163                    .lerp_to_gamma(theme.chart.axis_text, 0.035)
164                    .lerp_to_gamma(config.ask_color, if theme.dark { 0.065 } else { 0.035 });
165                painter.rect_filled(
166                    Rect::from_min_max(pos2(x, y0.min(y1)), pos2(x + width, y0.max(y1))),
167                    0.0,
168                    fill,
169                );
170            }
171        }
172        let area = config.value_area.and_then(|s| profile.volume.value_area(s));
173        let (high, low) = profile.nodes(config.node_sensitivity);
174        let poc = profile.volume.poc();
175        for row in profile.volume.levels.values() {
176            let y0 = mapping.price_to_y(row.level as f64 * step - tick * 0.5);
177            let y1 = mapping.price_to_y((row.level as f64 + 1.0) * step - tick * 0.5);
178            if !y0.is_finite() || !y1.is_finite() {
179                continue;
180            }
181            let mut top = y0.min(y1);
182            let mut bottom = y0.max(y1);
183            // Keep a physical-pixel gutter only when the row has room for it.
184            if bottom - top >= 2.0 * pixel {
185                top = (top / pixel).round() * pixel;
186                bottom = (bottom / pixel).round() * pixel - pixel;
187            }
188            let alpha = match (
189                area.is_some_and(|(a, b)| (a..=b).contains(&row.level)),
190                theme.dark,
191            ) {
192                (true, true) => 0.9,
193                (true, false) => 0.82,
194                (false, true) => 0.32,
195                (false, false) => 0.24,
196            };
197            let mut left = x;
198            let mode = config
199                .modes
200                .first()
201                .copied()
202                .unwrap_or(FootprintMode::BidAsk);
203            let segments = match mode {
204                FootprintMode::BidAsk if session => vec![
205                    (row.ask, config.ask_color),
206                    (row.bid, config.bid_color),
207                    (row.unknown, config.neutral_color),
208                ],
209                FootprintMode::BidAsk => vec![
210                    (row.bid, config.bid_color),
211                    (row.ask, config.ask_color),
212                    (row.unknown, config.neutral_color),
213                ],
214                FootprintMode::Volume => vec![(row.volume(), config.neutral_color)],
215                _ => {
216                    left += width * 0.5;
217                    vec![(
218                        row.delta().abs(),
219                        if row.delta() >= 0.0 {
220                            config.ask_color
221                        } else {
222                            config.bid_color
223                        },
224                    )]
225                }
226            };
227            for (volume, color) in segments {
228                let w = width
229                    * (volume / max) as f32
230                    * if matches!(mode, FootprintMode::Delta | FootprintMode::DeltaVolume) {
231                        0.5
232                    } else {
233                        1.0
234                    };
235                let right = if row.delta() < 0.0
236                    && matches!(mode, FootprintMode::Delta | FootprintMode::DeltaVolume)
237                {
238                    left - w
239                } else {
240                    left + w
241                };
242                painter.rect_filled(
243                    Rect::from_min_max(pos2(left.min(right), top), pos2(left.max(right), bottom)),
244                    0.0,
245                    color.gamma_multiply(alpha),
246                );
247                left = right;
248            }
249            if config.show_poc && poc == Some(row.level) {
250                painter.line_segment(
251                    [pos2(x, (y0 + y1) * 0.5), pos2(x + width, (y0 + y1) * 0.5)],
252                    Stroke::new(DESIGN_TOKENS.stroke.hairline, config.text_color),
253                );
254            }
255        }
256        // A range that starts before the first complete footprint bar leaves those bars out.
257        // No narrower profile has room for the note, so its text is never laid out.
258        if !profile.complete
259            && width >= 60.0
260            && let Some(&high) = profile.volume.levels.keys().next_back()
261        {
262            let y = mapping.price_to_y((high as f64 + 1.0) * step - tick * 0.5);
263            let galley = painter.layout_no_wrap(
264                crate::localization::text(painter.ctx(), "Partial executions"),
265                FontId::proportional(crate::styles::typography::XS),
266                theme.chart.axis_text_secondary,
267            );
268            let size = galley.size();
269            if y.is_finite() && size.x <= width {
270                painter.galley(pos2(x, y - size.y), galley, theme.chart.axis_text_secondary);
271            }
272        }
273        // A valley can lie on untraded rows, so nodes are placed by level, not by traded row.
274        for (label, nodes) in [("HVN", &high), ("LVN", &low)] {
275            for &level in nodes {
276                let y0 = mapping.price_to_y(level as f64 * step - tick * 0.5);
277                let y1 = mapping.price_to_y((level as f64 + 1.0) * step - tick * 0.5);
278                if y0.is_finite() && y1.is_finite() && (y0 - y1).abs() >= config.min_text_height {
279                    let galley = painter.layout_no_wrap(
280                        label.into(),
281                        FontId::monospace(crate::styles::typography::TINY),
282                        config.text_color,
283                    );
284                    if session && galley.size().x + 6.0 > width {
285                        continue;
286                    }
287                    let anchor = if session {
288                        Align2::RIGHT_CENTER
289                    } else {
290                        Align2::LEFT_CENTER
291                    };
292                    let pos = pos2(
293                        x + width + if session { -3.0 } else { 3.0 },
294                        (y0 + y1) * 0.5,
295                    );
296                    painter.galley(
297                        anchor.anchor_size(pos, galley.size()).min,
298                        galley,
299                        config.text_color,
300                    );
301                }
302            }
303        }
304        // The guides sit on the edges of the rows the shading covers.
305        if let Some((low_level, high_level)) = area {
306            for (stroke, edge) in [
307                (config.value_area_high, (high_level as f64 + 1.0) * step),
308                (config.value_area_low, low_level as f64 * step),
309            ] {
310                let y = mapping.price_to_y(edge - tick * 0.5);
311                if let Some(stroke) = stroke
312                    && y.is_finite()
313                {
314                    painter.line_segment([pos2(x, y), pos2(x + width, y)], stroke);
315                }
316            }
317        }
318        if settings.is_some_and(|s| !s.developing_poc) {
319            continue;
320        }
321        let mut previous = None;
322        for (at, &(time, poc)) in profile.developing_poc.iter().enumerate() {
323            // Earlier bars may still contribute volume after their candles were evicted.
324            if time < bars[0].time
325                && profile
326                    .developing_poc
327                    .get(at + 1)
328                    .is_some_and(|(next, _)| *next <= bars[0].time)
329            {
330                continue;
331            }
332            let index = bars.partition_point(|b| b.time < time);
333            let point = pos2(
334                mapping.idx_to_x(index),
335                mapping.price_to_y((poc as f64 + 0.5) * step - tick * 0.5),
336            );
337            if !point.is_finite() {
338                previous = None;
339                continue;
340            }
341            if let Some(old) = previous {
342                painter.line_segment([old, point], Stroke::new(1.0, config.text_color));
343            }
344            previous = Some(point);
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::data::Footprint;
353    use crate::model::{BarClock, OrderSide, Tick, Timeframe};
354    use crate::scales::{PriceMap, PriceRange, PriceTransform};
355    use crate::studies::{TradeProfileRanges, trade_profiles};
356    use chrono::TimeDelta;
357    use egui::{Shape, vec2};
358
359    /// A session can start before the viewport while its last bars remain visible. Its full
360    /// distribution stays visible, preserving volume proportions within its visible bars.
361    #[test]
362    fn partly_visible_sessions_keep_their_volume_profile_in_view() {
363        let time = "2026-01-05T10:00:00Z".parse().unwrap();
364        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
365        flow.push(&Tick::new(time, 100.0, 4.0, Some(OrderSide::Buy)))
366            .unwrap();
367        flow.push(&Tick::new(
368            time + TimeDelta::minutes(1),
369            101.0,
370            2.0,
371            Some(OrderSide::Sell),
372        ))
373        .unwrap();
374        let bars: Vec<_> = (0..4)
375            .map(|i| {
376                Bar::new(
377                    time + TimeDelta::minutes(i),
378                    100.0,
379                    101.0,
380                    100.0,
381                    101.0,
382                    0.0,
383                )
384            })
385            .collect();
386        let ranges = TradeProfileRanges::new(vec![time..time + TimeDelta::minutes(4)]).unwrap();
387        let profiles = trade_profiles(&flow, &ranges);
388        let rect = Rect::from_min_size(pos2(40.0, 20.0), vec2(240.0, 160.0));
389        let config = FootprintConfig {
390            modes: vec![FootprintMode::Volume],
391            value_area: None,
392            show_poc: false,
393            ..Default::default()
394        };
395        for (offset, column) in [
396            (0.0, Some((200.0, 56.0))),
397            (9.0, Some((40.0, 56.0))),
398            (11.0, Some((40.0, 19.0))),
399            (-5.0, None),
400            (20.0, None),
401        ] {
402            let mapping = ChartMapping::new(
403                rect,
404                20.0,
405                0,
406                3,
407                offset,
408                PriceMap::new(
409                    PriceTransform::default(),
410                    PriceRange::new(99.0, 103.0),
411                    20.0,
412                    180.0,
413                    false,
414                ),
415            );
416            let ctx = egui::Context::default();
417            let mut output = ctx.run_ui(
418                egui::RawInput {
419                    screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 200.0))),
420                    ..Default::default()
421                },
422                |ui| {
423                    render_trade_profiles(
424                        ui.painter(),
425                        &mapping,
426                        &bars,
427                        &profiles,
428                        1.0,
429                        1.0,
430                        &config,
431                    )
432                },
433            );
434            output.textures_delta.clear();
435            let columns: Vec<_> = output
436                .shapes
437                .iter()
438                .filter_map(|shape| {
439                    if let Shape::Rect(rect) = &shape.shape {
440                        Some((rect.rect.left(), rect.rect.width()))
441                    } else {
442                        None
443                    }
444                })
445                .collect();
446            let expected = column.map_or_else(Vec::new, |(left, width)| {
447                vec![(left, width), (left, width * 0.5)]
448            });
449            assert_eq!(columns, expected, "scroll offset {offset}");
450        }
451    }
452
453    /// The VAH and VAL guides lie on the outer edges of the rows the shading covers, each in
454    /// its own style, and either can be hidden.
455    #[test]
456    fn value_area_guides_follow_the_shaded_rows() {
457        let time = "2026-01-05T10:00:00Z".parse().unwrap();
458        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
459        for (price, size, side) in [
460            (100.0, 4.0, OrderSide::Buy),
461            (101.0, 1.0, OrderSide::Sell),
462            (102.0, 1.0, OrderSide::Sell),
463        ] {
464            flow.push(&Tick::new(time, price, size, Some(side)))
465                .unwrap();
466        }
467        let bars = [Bar::new(time, 100.0, 102.0, 100.0, 101.0, 0.0)];
468        let ranges = TradeProfileRanges::new(vec![time..time + TimeDelta::minutes(1)]).unwrap();
469        let profiles = trade_profiles(&flow, &ranges);
470        let rect = Rect::from_min_size(pos2(40.0, 20.0), vec2(240.0, 160.0));
471        let mapping = ChartMapping::new(
472            rect,
473            20.0,
474            0,
475            0,
476            0.0,
477            PriceMap::new(
478                PriceTransform::default(),
479                PriceRange::new(99.0, 104.0),
480                20.0,
481                180.0,
482                false,
483            ),
484        );
485        let (red, blue) = (
486            Stroke::new(1.0, egui::Color32::RED),
487            Stroke::new(2.0, egui::Color32::BLUE),
488        );
489        let guides = |high, low| {
490            let config = FootprintConfig {
491                value_area: Some(0.6),
492                value_area_high: high,
493                value_area_low: low,
494                ..Default::default()
495            };
496            let ctx = egui::Context::default();
497            let mut output = ctx.run_ui(
498                egui::RawInput {
499                    screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 200.0))),
500                    ..Default::default()
501                },
502                |ui| {
503                    render_trade_profiles(
504                        ui.painter(),
505                        &mapping,
506                        &bars,
507                        &profiles,
508                        1.0,
509                        1.0,
510                        &config,
511                    )
512                },
513            );
514            output.textures_delta.clear();
515            output
516                .shapes
517                .iter()
518                .filter_map(|shape| match &shape.shape {
519                    Shape::LineSegment { points, stroke } if *stroke == red || *stroke == blue => {
520                        Some((points[0].y, stroke.color))
521                    }
522                    _ => None,
523                })
524                .collect::<Vec<_>>()
525        };
526        // Level 100 holds 4 of 6 contracts, which meets the 60% share on its own.
527        let top = mapping.price_to_y(100.5);
528        let bottom = mapping.price_to_y(99.5);
529        assert_eq!(
530            guides(Some(red), Some(blue)),
531            [(top, egui::Color32::RED), (bottom, egui::Color32::BLUE)]
532        );
533        assert_eq!(guides(None, Some(blue)), [(bottom, egui::Color32::BLUE)]);
534        assert_eq!(guides(Some(red), None), [(top, egui::Color32::RED)]);
535    }
536}