Skip to main content

egui_charts/chart/overlays/
book_profile.rs

1//! The resting size at each price of the live book, as bars at the right edge of the pane.
2
3use crate::chart::coords::ChartMapping;
4use crate::data::{
5    OrderFilter, OrderFilterUnavailable, OrderQueue, OrderSizeTier, OrderSizeTiers, RestingOrder,
6};
7use crate::model::{OrderBook, OrderSide};
8use crate::theme::{ChartColors, ThemeExt};
9use egui::{Color32, FontId, Painter, Rect, Stroke, StrokeKind, pos2, vec2};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13/// What the length of a bar of the book profile shows.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub enum BookProfile {
16    /// The total resting size of the orders at the price.
17    #[default]
18    Total,
19    /// The size of the largest known individual order at the price. Needs order events.
20    LargestOrder,
21}
22
23/// How the live book is shown by the heatmap's profile and by a depth ladder: which individual
24/// orders count, what a bar measures, and which orders are highlighted. Its tiers also mark the
25/// heatmap's cells. The defaults show the aggregate book as the feed sends it. Serializable, so a
26/// host can keep it with its chart settings; fields missing from older documents take their
27/// defaults.
28#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
29#[serde(default)]
30pub struct OrderBookView {
31    /// The orders that count. Anything but the default needs a feed that sends order events,
32    /// see [`Sink::push_order_event`](crate::data::Sink::push_order_event).
33    pub filter: OrderFilter,
34    /// What a bar of the profile measures.
35    pub profile: BookProfile,
36    /// Quantity unit supplied by the host, such as "lots", "shares" or "BTC". Labels use
37    /// "feed units" when absent or blank. This describes the supplied sizes, without conversion;
38    /// it is independent of the instrument's price unit.
39    pub quantity_unit: Option<String>,
40    /// Highlights a price whose largest known individual order reaches a tier. A price without
41    /// order data, as with an aggregate feed, is not highlighted and not reported as small. The
42    /// liquidity heatmap marks its cells by the largest order its history recorded in the row, see
43    /// [`DepthColumn::largest_bid_orders`](crate::data::DepthColumn::largest_bid_orders).
44    pub tiers: Option<OrderSizeTiers>,
45    /// Colours of the notable and the large tier, or `None`, the default, for the
46    /// [`ChartColors::order_notable`] and [`order_large`](ChartColors::order_large) of the theme
47    /// the view is drawn with, so the marks follow a change of theme.
48    pub tier_colors: Option<[Color32; 2]>,
49}
50
51impl OrderBookView {
52    /// Whether the view needs individual orders: a filter or the largest-order profile.
53    fn needs_orders(&self) -> bool {
54        self.filter != OrderFilter::default() || self.profile == BookProfile::LargestOrder
55    }
56
57    /// The colour of a tier: from [`tier_colors`](Self::tier_colors) when set, otherwise from
58    /// `colors`, the chart colours of the theme the view is drawn with.
59    pub fn tier_color(&self, tier: OrderSizeTier, colors: &ChartColors) -> Color32 {
60        let [notable, large] = self
61            .tier_colors
62            .unwrap_or([colors.order_notable, colors.order_large]);
63        if tier == OrderSizeTier::Large {
64            large
65        } else {
66            notable
67        }
68    }
69}
70
71/// Draws the profile of `queue`'s orders, or of `book` when the feed sends aggregate depth only,
72/// as bars growing left from the right edge of `mapping.rect`, longest 60 points, `tick` high at
73/// each price, centred on it. Bids use `colors[0]` and asks `colors[1]`, unless the largest
74/// order at the price reaches a tier of the view, drawn in [`OrderBookView::tier_color`] with the
75/// painter's active theme. Fails, drawing nothing, when the view needs
76/// individual orders and the feed has none, so that the host can say the filtering is
77/// unavailable. A key identifies the measure, quantity unit and maximum across all supplied
78/// levels, including offscreen ones, after filtering. Hovering a row anywhere in the 60-point
79/// strip shows its exact price, side and measured resting size, without abbreviating quantities.
80pub fn render_book_profile(
81    painter: &Painter,
82    mapping: &ChartMapping,
83    book: Option<&OrderBook>,
84    queue: Option<&OrderQueue>,
85    tick: f64,
86    view: &OrderBookView,
87    colors: [Color32; 2],
88) -> Result<(), OrderFilterUnavailable> {
89    if !tick.is_finite() || tick <= 0.0 {
90        return Ok(());
91    }
92    let level = |price: f64| (price / tick).round() as i64;
93    // Bars as (price, is bid, length measure, tier).
94    let mut bars: Vec<(f64, bool, f64, Option<OrderSizeTier>)> = Vec::new();
95    match (queue, book) {
96        (Some(queue), _) => {
97            let summed = queue.filtered("", &view.filter)?;
98            let largest = queue.largest_orders(&view.filter);
99            let tier = |order: &RestingOrder| view.tiers.and_then(|tiers| tiers.tier(order.size));
100            let tiers: BTreeMap<_, _> = largest
101                .iter()
102                .map(|o| ((o.side == OrderSide::Buy, level(o.price)), tier(o)))
103                .collect();
104            match view.profile {
105                BookProfile::Total => {
106                    for (bid, levels) in [(true, &summed.bids), (false, &summed.asks)] {
107                        for l in levels {
108                            let at = tiers.get(&(bid, level(l.price))).copied().flatten();
109                            bars.push((l.price, bid, l.size, at));
110                        }
111                    }
112                }
113                BookProfile::LargestOrder => bars.extend(
114                    largest
115                        .iter()
116                        .map(|o| (o.price, o.side == OrderSide::Buy, o.size, tier(o))),
117                ),
118            }
119        }
120        (None, _) if view.needs_orders() => return Err(OrderFilterUnavailable::AggregateDepth),
121        (None, Some(book)) => {
122            for (bid, levels) in [(true, &book.bids), (false, &book.asks)] {
123                bars.extend(levels.iter().map(|l| (l.price, bid, l.size, None)));
124            }
125        }
126        (None, None) => {}
127    }
128    let max = bars.iter().map(|b| b.2).fold(0.0, f64::max);
129    if max <= 0.0 {
130        return Ok(());
131    }
132    let theme = painter.ctx().active_theme();
133    let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
134    let pointer = painter
135        .ctx()
136        .pointer_hover_pos()
137        .filter(|p| painter.clip_rect().contains(*p) && p.x >= mapping.rect.right() - 60.0);
138    let mut hovered = None;
139    for (price, bid, size, tier) in bars {
140        let color = tier.map_or(colors[usize::from(!bid)], |tier| {
141            view.tier_color(tier, &theme.chart)
142        });
143        let (y0, y1) = (
144            mapping.price_to_y(price - tick * 0.5),
145            mapping.price_to_y(price + tick * 0.5),
146        );
147        let rect = Rect::from_min_max(
148            pos2(
149                mapping.rect.right() - 60.0 * (size / max) as f32,
150                y0.min(y1),
151            ),
152            pos2(mapping.rect.right(), y0.max(y1)),
153        );
154        if rect.is_finite() && rect.intersects(mapping.rect) {
155            painter.rect_filled(rect, 0.0, color.gamma_multiply(0.7));
156            if pointer.is_some_and(|p| rect.y_range().contains(p.y)) {
157                hovered = Some((price, bid, size));
158                painter.rect_stroke(
159                    rect,
160                    0.0,
161                    Stroke::new(1.0, theme.chart.axis_text),
162                    StrokeKind::Inside,
163                );
164            }
165        }
166    }
167    let ctx = painter.ctx();
168    let unit = view
169        .quantity_unit
170        .as_deref()
171        .map(str::trim)
172        .filter(|unit| !unit.is_empty())
173        .map_or_else(
174            || crate::localization::text(ctx, "feed units"),
175            str::to_owned,
176        );
177    let measure = crate::localization::text(
178        ctx,
179        match view.profile {
180            BookProfile::Total => "Resting total",
181            BookProfile::LargestOrder => "Largest resting order",
182        },
183    );
184    let key = crate::localization::format_text(
185        ctx,
186        "{measure} ({unit})\nFull width = {max} {unit}\nAll supplied levels, after filtering",
187        &[
188            ("measure", measure.as_str()),
189            ("unit", &unit),
190            ("max", &max.to_string()),
191        ],
192    );
193    // Liquidity paints before candles; keep its readout above the series in the same pane.
194    let parent = painter.layer_id();
195    let layer = egui::LayerId::new(parent.order, parent.id.with("book_profile_readout"));
196    ctx.set_sublayer(parent, layer);
197    let painter = painter.clone().with_layer_id(layer);
198    let label = |text: String, bottom: f32| {
199        let left = painter.clip_rect().left() + 4.0;
200        let right = (mapping.rect.right() - 64.0).min(painter.clip_rect().right() - 4.0);
201        let galley = painter.layout(
202            text,
203            FontId::proportional(11.0),
204            theme.ui.text,
205            (right - left - 12.0).max(1.0),
206        );
207        let size = galley.size() + vec2(12.0, 8.0);
208        let at = pos2(
209            (right - size.x).max(left),
210            (bottom - size.y).max(painter.clip_rect().top() + 4.0),
211        );
212        let rect = Rect::from_min_size(at, size);
213        painter.rect_filled(rect, 3.0, theme.ui.panel_bg_floating);
214        painter.rect_stroke(
215            rect,
216            3.0,
217            Stroke::new(1.0, theme.ui.border),
218            StrokeKind::Inside,
219        );
220        painter.galley(at + vec2(6.0, 4.0), galley, theme.ui.text);
221        rect
222    };
223    let key_rect = label(key, painter.clip_rect().bottom() - 4.0);
224    if let Some((price, bid, size)) = hovered {
225        let text = crate::localization::format_text(
226            ctx,
227            "{side} · Price {price}\n{measure}: {size} {unit}",
228            &[
229                (
230                    "side",
231                    crate::localization::text(ctx, if bid { "Bid" } else { "Ask" }),
232                ),
233                ("price", price.to_string()),
234                ("measure", measure),
235                ("size", size.to_string()),
236                ("unit", unit),
237            ],
238        );
239        label(
240            text,
241            (mapping.price_to_y(price) - 4.0).min(key_rect.top() - 4.0),
242        );
243    }
244    Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::model::OrderBookLevel;
251    use crate::scales::{PriceMap, PriceRange, PriceTransform};
252    use egui::epaint::Shape;
253
254    /// Guards the profile rules: a bar measures the total at the price or its largest order, a
255    /// price is highlighted by the tier of its largest known order whichever the measure, an
256    /// aggregate feed has no known order, so nothing is highlighted and a view that needs orders
257    /// fails instead of drawing aggregate sizes, and the length of a bar is its share of the
258    /// longest, 60 points.
259    #[test]
260    fn bars_follow_the_measure_and_highlight_the_tier_of_the_largest_order() {
261        let time = "2026-01-05T10:00:00Z".parse().unwrap();
262        let mut queue = OrderQueue::new(Some(0.25));
263        let orders = [(1, 99.0, 3.0), (2, 99.0, 9.0), (3, 98.75, 2.0)]
264            .map(|(id, price, size)| RestingOrder::new(id, OrderSide::Buy, price, size));
265        queue.snapshot(1, time, orders.to_vec()).unwrap();
266        let aggregate = OrderBook {
267            ts: time,
268            symbol: "A".into(),
269            bids: vec![OrderBookLevel {
270                price: 99.0,
271                size: 12.0,
272                order_cnt: None,
273            }],
274            asks: vec![],
275        };
276        let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 200.0));
277        let map = ChartMapping::new(
278            rect,
279            10.0,
280            0,
281            0,
282            0.0,
283            PriceMap::new(
284                PriceTransform::default(),
285                PriceRange::new(98.0, 100.0),
286                0.0,
287                200.0,
288                false,
289            ),
290        );
291        let (bid, ask) = (Color32::GREEN, Color32::RED);
292        let view = |profile| OrderBookView {
293            profile,
294            tiers: Some(OrderSizeTiers {
295                notable: 5.0,
296                large: 10.0,
297            }),
298            ..Default::default()
299        };
300        let notable = view(BookProfile::Total)
301            .tier_color(OrderSizeTier::Notable, &crate::theme::Theme::dark().chart);
302        let draw = |view: &OrderBookView, queue, book| {
303            let ctx = egui::Context::default();
304            let mut result = Ok(());
305            let mut output = ctx.run_ui(
306                egui::RawInput {
307                    screen_rect: Some(rect),
308                    ..Default::default()
309                },
310                |ui| {
311                    result = render_book_profile(
312                        ui.painter(),
313                        &map,
314                        book,
315                        queue,
316                        0.25,
317                        view,
318                        [bid, ask],
319                    );
320                },
321            );
322            output.textures_delta.clear();
323            let bars = Vec::from_iter(output.shapes.iter().filter_map(|s| {
324                match &s.shape {
325                    Shape::Rect(r)
326                        if [bid, notable]
327                            .map(|c| c.gamma_multiply(0.7))
328                            .contains(&r.fill) =>
329                    {
330                        Some((r.rect.width().round(), r.fill))
331                    }
332                    _ => None,
333                }
334            }));
335            (result, bars)
336        };
337        let [bid_fill, tier_fill] = [bid, notable].map(|c| c.gamma_multiply(0.7));
338        // Total: 12 and 2 against 12; the largest order at 99.0 is 9, a notable one.
339        let (result, bars) = draw(&view(BookProfile::Total), Some(&queue), None);
340        assert_eq!(result, Ok(()));
341        assert_eq!(bars, [(60.0, tier_fill), (10.0, bid_fill)]);
342        // Largest order: 9 and 2 against 9.
343        let (_, bars) = draw(&view(BookProfile::LargestOrder), Some(&queue), None);
344        assert_eq!(bars, [(60.0, tier_fill), (13.0, bid_fill)]);
345        // Aggregate depth: the total, with no known order to highlight; the largest order has
346        // no answer.
347        let (result, bars) = draw(&view(BookProfile::Total), None, Some(&aggregate));
348        assert_eq!((result, bars), (Ok(()), vec![(60.0, bid_fill)]));
349        let (result, bars) = draw(&view(BookProfile::LargestOrder), None, Some(&aggregate));
350        assert_eq!(
351            (result, bars),
352            (Err(OrderFilterUnavailable::AggregateDepth), vec![])
353        );
354    }
355
356    /// The maximum includes offscreen levels and uses the same filtered measure as the bars;
357    /// hover reads the whole strip, even where a small bar does not reach the pointer.
358    /// Captions fit beside the strip in narrow panes.
359    #[test]
360    fn readout_quantifies_the_full_book_and_the_hovered_row() {
361        let time = "2026-01-05T10:00:00Z".parse().unwrap();
362        let mut queue = OrderQueue::new(Some(0.25));
363        queue
364            .snapshot(
365                1,
366                time,
367                vec![
368                    RestingOrder::new(1, OrderSide::Buy, 99.75, 25.125),
369                    RestingOrder::new(2, OrderSide::Sell, 101.25, 600.0),
370                    RestingOrder::new(3, OrderSide::Sell, 101.25, 400.0),
371                    RestingOrder::new(4, OrderSide::Sell, 120.0, 2000.0),
372                ],
373            )
374            .unwrap();
375        let book = queue.filtered("A", &OrderFilter::default()).unwrap();
376        for width in [200.0, 800.0, 1440.0] {
377            let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(width, 600.0));
378            let map = ChartMapping::new(
379                rect,
380                10.0,
381                0,
382                0,
383                0.0,
384                PriceMap::new(
385                    PriceTransform::default(),
386                    PriceRange::new(99.0, 102.0),
387                    0.0,
388                    600.0,
389                    false,
390                ),
391            );
392            for theme in [crate::theme::Theme::dark(), crate::theme::Theme::light()] {
393                for (profile, unit, orders, minimum, price, from_right, measure, size, side) in [
394                    (
395                        BookProfile::Total,
396                        Some("lots"),
397                        false,
398                        0.0,
399                        101.25,
400                        50.0,
401                        "Resting total",
402                        "1000",
403                        "Ask",
404                    ),
405                    (
406                        BookProfile::LargestOrder,
407                        Some("lots"),
408                        true,
409                        0.0,
410                        101.25,
411                        50.0,
412                        "Largest resting order",
413                        "600",
414                        "Ask",
415                    ),
416                    (
417                        BookProfile::Total,
418                        Some("lots"),
419                        true,
420                        500.0,
421                        101.25,
422                        50.0,
423                        "Resting total",
424                        "600",
425                        "Ask",
426                    ),
427                    (
428                        BookProfile::Total,
429                        None,
430                        false,
431                        0.0,
432                        99.75,
433                        50.0,
434                        "Resting total",
435                        "25.125",
436                        "Bid",
437                    ),
438                    (
439                        BookProfile::Total,
440                        Some("  "),
441                        false,
442                        0.0,
443                        101.25,
444                        50.0,
445                        "Resting total",
446                        "1000",
447                        "Ask",
448                    ),
449                    (
450                        BookProfile::Total,
451                        Some("lots"),
452                        false,
453                        0.0,
454                        101.25,
455                        100.0,
456                        "Resting total",
457                        "1000",
458                        "Ask",
459                    ),
460                ] {
461                    // Deserialize so the regression also compiles against views predating the unit.
462                    let mut view: OrderBookView = serde_json::from_value(serde_json::json!({
463                        "quantity_unit": unit,
464                    }))
465                    .unwrap();
466                    view.profile = profile;
467                    view.filter.min_size = minimum;
468                    let ctx = egui::Context::default();
469                    crate::theme::apply_to_egui(&ctx, &theme);
470                    let mut output = ctx.run_ui(
471                        egui::RawInput {
472                            screen_rect: Some(rect),
473                            events: vec![egui::Event::PointerMoved(pos2(
474                                width - from_right,
475                                map.price_to_y(price),
476                            ))],
477                            ..Default::default()
478                        },
479                        |ui| {
480                            render_book_profile(
481                                ui.painter(),
482                                &map,
483                                Some(&book),
484                                orders.then_some(&queue),
485                                0.25,
486                                &view,
487                                [Color32::GREEN, Color32::RED],
488                            )
489                            .unwrap();
490                        },
491                    );
492                    output.textures_delta.clear();
493                    for clipped in &output.shapes {
494                        if let Shape::Text(text) = &clipped.shape {
495                            let bounds = text.visual_bounding_rect();
496                            assert!(
497                                clipped.clip_rect.contains_rect(bounds),
498                                "clipped readout: {bounds:?}"
499                            );
500                            assert!(
501                                bounds.right() <= rect.right() - 64.0,
502                                "readout overlaps the strip: {bounds:?}"
503                            );
504                        }
505                    }
506                    let texts: Vec<_> = output
507                        .shapes
508                        .iter()
509                        .filter_map(|s| match &s.shape {
510                            Shape::Text(t) => Some(t.galley.text()),
511                            _ => None,
512                        })
513                        .collect();
514                    let unit = unit
515                        .filter(|unit| !unit.trim().is_empty())
516                        .unwrap_or("feed units");
517                    assert!(texts.contains(&format!("{measure} ({unit})\nFull width = 2000 {unit}\nAll supplied levels, after filtering").as_str()), "{texts:?}");
518                    let hover = format!("{side} · Price {price}\n{measure}: {size} {unit}");
519                    assert_eq!(
520                        texts.contains(&hover.as_str()),
521                        from_right <= 60.0,
522                        "{texts:?}"
523                    );
524                    // Hand calculation: a 1000-lot shelf against an offscreen 2000-lot max is
525                    // 60 * 1000 / 2000 = 30 points, or 18 for the 600-lot measure.
526                    if price == 101.25 {
527                        let width = 60.0 * size.parse::<f32>().unwrap() / 2000.0;
528                        assert!(output.shapes.iter().any(|s| matches!(&s.shape,
529                        Shape::Rect(r) if r.rect.contains(pos2(rect.right() - 1.0, map.price_to_y(price)))
530                            && (r.rect.width() - width).abs() < 0.001
531                    )));
532                    }
533                }
534            }
535        }
536    }
537}