Skip to main content

egui_charts/chart/overlays/
liquidity_heatmap.rs

1//! Retained textures of resting liquidity, one texel per sampled column and price row.
2
3use super::OrderBookView;
4use crate::chart::coords::ChartMapping;
5use crate::data::{DepthColumn, DepthHistory, OrderSizeTier};
6use crate::theme::ThemeExt;
7use chrono::{DateTime, Utc};
8use egui::{Color32, ColorImage, Painter, Rect, TextureHandle, TextureOptions, pos2};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
12
13type Key = (DateTime<Utc>, chrono::TimeDelta);
14
15struct Column {
16    source: Arc<DepthColumn>,
17    slot: usize,
18    rows: BTreeSet<i64>,
19}
20
21/// Brightness and resting-size range of the liquidity heatmap. Defaults retain logarithmic
22/// shading against each column's largest size, at 75% opacity. Does not change the sampled depth,
23/// best bid/ask lines or live book profile. Serializable for host chart settings.
24#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct LiquidityHeatmapConfig {
27    /// Multiplies cell opacity, including order tier marks, saturating at full opacity.
28    /// Defaults to 1. Negative values act as zero; non-finite values use the default.
29    pub brightness: f32,
30    /// Cells with total resting size at or below this value stay transparent, including marks.
31    /// Defaults to 0. Negative or non-finite values act as zero.
32    pub lower_cutoff: f64,
33    /// Size that reaches full intensity: `ln(1 + size) / ln(1 + full_intensity_size)`, capped at 1.
34    /// `None`, the default, uses each column's largest total. Non-positive or non-finite sizes
35    /// also use that per-column scale. The cutoff does not shift the logarithmic curve.
36    pub full_intensity_size: Option<f64>,
37}
38
39impl Default for LiquidityHeatmapConfig {
40    fn default() -> Self {
41        Self {
42            brightness: 1.0,
43            lower_cutoff: 0.0,
44            full_intensity_size: None,
45        }
46    }
47}
48
49impl LiquidityHeatmapConfig {
50    fn normalized(self) -> Self {
51        Self {
52            brightness: if self.brightness.is_finite() {
53                self.brightness.max(0.0)
54            } else {
55                1.0
56            },
57            lower_cutoff: if self.lower_cutoff.is_finite() {
58                self.lower_cutoff.max(0.0)
59            } else {
60                0.0
61            },
62            full_intensity_size: self
63                .full_intensity_size
64                .filter(|s| s.is_finite() && *s > 0.0),
65        }
66    }
67}
68
69/// A liquidity heatmap backed by bounded texture tiles. Retain it between frames.
70///
71/// Each column uses logarithmic contrast against its largest resting size unless [`Self::config`]
72/// sets a shared full-intensity size. A cell whose largest recorded order reaches a size tier of
73/// the [`OrderBookView`] takes that tier's colour over its
74/// shading. Empty prices and missing time periods stay transparent. Old columns release their
75/// atlas slots; new or changed columns upload only their own texels. Changed colours, tiers or
76/// appearance upload every held column once. No texture side exceeds the backend's limit.
77#[derive(Default)]
78pub struct LiquidityHeatmap {
79    /// Cell brightness and size range, applied on the next render. Unchanged effective settings
80    /// upload nothing; changed settings redraw every held column once.
81    pub config: LiquidityHeatmapConfig,
82    drawn_config: Option<LiquidityHeatmapConfig>,
83    columns: BTreeMap<Key, Column>,
84    tiles: BTreeMap<(usize, i64), TextureHandle>,
85    size: usize,
86    colors: Option<[Color32; 2]>,
87    /// The size tiers, by bit pattern, and the tier colours the held texels were drawn with.
88    marks: Option<([u64; 2], [Color32; 2])>,
89}
90
91impl LiquidityHeatmap {
92    /// Draws the history using the pane's price mapping and the host's time-to-x conversion.
93    /// `step` is the instrument tick size times the history's ticks per row. `view` gives the size
94    /// tiers and their colours, as for the book profile, from the painter's active theme when the
95    /// view sets none; its filter and profile do not apply, since
96    /// the history sampled the orders of its own
97    /// [`DepthHistoryConfig::orders`](crate::data::DepthHistoryConfig::orders). Buy and sell
98    /// colours follow the painter's host; call with the same colours as the price series.
99    pub fn render(
100        &mut self,
101        painter: &Painter,
102        mapping: &ChartMapping,
103        history: &DepthHistory,
104        step: f64,
105        time_to_x: impl Fn(DateTime<Utc>) -> Option<f32>,
106        view: &OrderBookView,
107        colors: [Color32; 2],
108    ) {
109        if !step.is_finite() || step <= 0.0 {
110            return;
111        }
112        let half_tick = step / f64::from(history.config().ticks_per_row) * 0.5;
113        let size = painter.ctx().input(|i| i.max_texture_side).clamp(1, 256);
114        let theme = painter.ctx().active_theme().chart;
115        // Tiers by bit pattern, so tiers that are not numbers do not redraw every frame, and the
116        // resolved colours, so a change of theme redraws the held columns.
117        let marks = view.tiers.map(|t| {
118            (
119                [t.notable.to_bits(), t.large.to_bits()],
120                [OrderSizeTier::Notable, OrderSizeTier::Large]
121                    .map(|tier| view.tier_color(tier, &theme)),
122            )
123        });
124        let config = self.config.normalized();
125        if self.size != size
126            || self.colors != Some(colors)
127            || self.marks != marks
128            || self.drawn_config != Some(config)
129        {
130            *self = Self {
131                config: self.config,
132                drawn_config: Some(config),
133                size,
134                colors: Some(colors),
135                marks,
136                ..Self::default()
137            };
138        }
139        let keys: BTreeSet<_> = history.columns().map(|c| (c.time, c.span)).collect();
140        self.columns.retain(|key, _| keys.contains(key));
141        let mut used: BTreeSet<_> = self.columns.values().map(|c| c.slot).collect();
142        for source in history.shared() {
143            let key = (source.time, source.span);
144            if self
145                .columns
146                .get(&key)
147                .is_some_and(|c| Arc::ptr_eq(&c.source, source))
148            {
149                continue;
150            }
151            let slot = self.columns.get(&key).map_or_else(
152                || {
153                    (0..)
154                        .find(|s| !used.contains(s))
155                        .expect("a free atlas slot")
156                },
157                |c| c.slot,
158            );
159            used.insert(slot);
160            let mut pixels: BTreeMap<i64, Vec<Color32>> = BTreeMap::new();
161            // Bid size, ask size and the largest known order of each row, 0 when none is known.
162            let mut volumes: BTreeMap<i64, [f64; 3]> = BTreeMap::new();
163            for (side, (levels, largest)) in [
164                (&source.bids, &source.largest_bid_orders),
165                (&source.asks, &source.largest_ask_orders),
166            ]
167            .into_iter()
168            .enumerate()
169            {
170                for (at, &(row, volume)) in levels.iter().enumerate() {
171                    let cell = volumes.entry(row).or_default();
172                    cell[side] += volume;
173                    cell[2] = cell[2].max(largest.get(at).copied().unwrap_or(0.0));
174                }
175            }
176            let max = config
177                .full_intensity_size
178                .unwrap_or_else(|| volumes.values().map(|v| v[0] + v[1]).fold(0.0, f64::max));
179            for (row, volume) in volumes {
180                let total = volume[0] + volume[1];
181                let tile = row.div_euclid(size as i64);
182                let pixels = pixels
183                    .entry(tile)
184                    .or_insert_with(|| vec![Color32::TRANSPARENT; size]);
185                if total <= config.lower_cutoff {
186                    continue;
187                }
188                let intensity = if max > 0.0 {
189                    (total.ln_1p() / max.ln_1p()).min(1.0) as f32
190                } else {
191                    0.0
192                };
193                let color = colors[0].lerp_to_gamma(
194                    colors[1],
195                    if total > 0.0 {
196                        (volume[1] / total) as f32
197                    } else {
198                        0.5
199                    },
200                );
201                let shaded = color.gamma_multiply(intensity * 0.75);
202                // A row without a known order is never marked, whatever the tiers.
203                let tier = view
204                    .tiers
205                    .filter(|_| volume[2] > 0.0)
206                    .and_then(|tiers| tiers.tier(volume[2]));
207                pixels[row.rem_euclid(size as i64) as usize] = tier
208                    .map_or(shaded, |tier| shaded.blend(view.tier_color(tier, &theme)))
209                    .gamma_multiply(config.brightness);
210            }
211            let rows = pixels.keys().copied().collect();
212            for (row, pixels) in pixels {
213                let tile = self.tiles.entry((slot / size, row)).or_insert_with(|| {
214                    painter.ctx().load_texture(
215                        "liquidity",
216                        ColorImage::filled([size, size], Color32::TRANSPARENT),
217                        TextureOptions::NEAREST,
218                    )
219                });
220                tile.set_partial(
221                    [slot % size, 0],
222                    ColorImage::new([1, size], pixels),
223                    TextureOptions::NEAREST,
224                );
225            }
226            self.columns.insert(
227                key,
228                Column {
229                    source: Arc::clone(source),
230                    slot,
231                    rows,
232                },
233            );
234        }
235        let alive: BTreeSet<_> = self
236            .columns
237            .values()
238            .flat_map(|c| c.rows.iter().map(|r| (c.slot / size, *r)))
239            .collect();
240        self.tiles.retain(|key, _| alive.contains(key));
241        let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
242        for column in self.columns.values() {
243            let Some(end) = column.source.time.checked_add_signed(column.source.span) else {
244                continue;
245            };
246            let (Some(left), Some(right)) = (time_to_x(column.source.time), time_to_x(end)) else {
247                continue;
248            };
249            if right < mapping.rect.left() || left > mapping.rect.right() {
250                continue;
251            }
252            for &row in &column.rows {
253                let nonlinear =
254                    mapping.price.transform().mode == crate::scales::PriceScaleMode::Logarithmic;
255                let mut mesh =
256                    egui::Mesh::with_texture(self.tiles[&(column.slot / size, row)].id());
257                // A logarithmic price scale needs one mapped edge per price row; a linear
258                // scale can draw the whole tile with one quad.
259                let stride = if nonlinear { 1 } else { size };
260                for offset in (0..size).step_by(stride) {
261                    let lo = (row as f64 * size as f64 + offset as f64) * step - half_tick;
262                    let hi = lo + stride as f64 * step;
263                    if nonlinear && lo <= 0.0 {
264                        continue;
265                    }
266                    let y0 = mapping.price_to_y(lo);
267                    let y1 = mapping.price_to_y(hi);
268                    if !y0.is_finite() || !y1.is_finite() {
269                        continue;
270                    }
271                    let rect = Rect::from_min_max(pos2(left, y0.min(y1)), pos2(right, y0.max(y1)));
272                    if !rect.intersects(mapping.rect) {
273                        continue;
274                    }
275                    let u0 = (column.slot % size) as f32 / size as f32;
276                    let u1 = (column.slot % size + 1) as f32 / size as f32;
277                    let low = offset as f32 / size as f32;
278                    let high = (offset + stride) as f32 / size as f32;
279                    let (v0, v1) = if y0 > y1 { (high, low) } else { (low, high) };
280                    mesh.add_rect_with_uv(
281                        rect,
282                        Rect::from_min_max(pos2(u0, v0), pos2(u1, v1)),
283                        Color32::WHITE,
284                    );
285                }
286                if !mesh.is_empty() {
287                    painter.add(egui::Shape::mesh(mesh));
288                }
289            }
290            for (levels, color) in [
291                (&column.source.bids, colors[0]),
292                (&column.source.asks, colors[1]),
293            ] {
294                if let Some(&(level, _)) = levels.first() {
295                    let y = mapping.price_to_y(level as f64 * step - half_tick);
296                    if y.is_finite() {
297                        painter.line_segment(
298                            [pos2(left, y), pos2(right, y)],
299                            egui::Stroke::new(1.0, color),
300                        );
301                    }
302                }
303            }
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::scales::{PriceMap, PriceRange, PriceTransform};
312    use crate::theme::Theme;
313    use chrono::TimeDelta;
314
315    #[test]
316    fn history_and_appearance_upload_only_changed_texels() {
317        let ctx = egui::Context::default();
318        let mut history = DepthHistory::new(Default::default()).unwrap();
319        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
320        let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
321        let mapping = ChartMapping::new(
322            rect,
323            10.0,
324            0,
325            10,
326            0.0,
327            PriceMap::new(
328                PriceTransform::default(),
329                PriceRange::new(95.0, 105.0),
330                0.0,
331                100.0,
332                false,
333            ),
334        );
335        let mut heatmap = LiquidityHeatmap::default();
336        let defaults = LiquidityHeatmapConfig::default();
337        let shared = LiquidityHeatmapConfig {
338            full_intensity_size: Some(999.0),
339            ..defaults
340        };
341        let dimmed = LiquidityHeatmapConfig {
342            brightness: 0.5,
343            lower_cutoff: 10.0,
344            ..shared
345        };
346        let invalid = LiquidityHeatmapConfig {
347            brightness: f32::NAN,
348            lower_cutoff: f64::INFINITY,
349            full_intensity_size: Some(f64::NAN),
350        };
351        // ln(10)/ln(100) = 1/2; ln(10)/ln(1000) = 1/3;
352        // ln(100)/ln(1000) = 2/3. Alpha is rounded from 255 * 0.75 * intensity.
353        // In the deep column, ln(1000)/ln(2001) = 0.90875, giving alpha 174.
354        for (append, config, expected) in [
355            (Some([9.0, 99.0]), defaults, vec![[96, 191]]),
356            (None, defaults, vec![]),
357            (Some([999.0, 2000.0]), defaults, vec![[174, 191]]),
358            (None, shared, vec![[64, 128], [191, 191]]),
359            (None, shared, vec![]),
360            (None, dimmed, vec![[0, 64], [96, 96]]),
361            (None, dimmed, vec![]),
362            (
363                None,
364                LiquidityHeatmapConfig {
365                    lower_cutoff: 99.0,
366                    ..dimmed
367                },
368                vec![[0, 0], [96, 96]],
369            ),
370            (
371                None,
372                LiquidityHeatmapConfig {
373                    brightness: 0.0,
374                    ..shared
375                },
376                vec![[0, 0], [0, 0]],
377            ),
378            (
379                None,
380                LiquidityHeatmapConfig {
381                    brightness: 2.0,
382                    ..shared
383                },
384                vec![[128, 255], [255, 255]],
385            ),
386            (None, invalid, vec![[96, 191], [174, 191]]),
387            (None, invalid, vec![]),
388            (None, defaults, vec![]),
389            (
390                None,
391                LiquidityHeatmapConfig {
392                    lower_cutoff: -1.0,
393                    full_intensity_size: Some(0.0),
394                    ..defaults
395                },
396                vec![],
397            ),
398            (
399                None,
400                LiquidityHeatmapConfig {
401                    full_intensity_size: Some(f64::INFINITY),
402                    ..defaults
403                },
404                vec![],
405            ),
406            (
407                None,
408                LiquidityHeatmapConfig {
409                    brightness: -1.0,
410                    ..defaults
411                },
412                vec![[0, 0], [0, 0]],
413            ),
414        ] {
415            if let Some([bid, ask]) = append {
416                let second = history.columns().count() as i64;
417                history.push(DepthColumn {
418                    time: start + TimeDelta::seconds(second),
419                    span: TimeDelta::seconds(1),
420                    bids: vec![(100, bid)],
421                    asks: vec![(101, ask)],
422                    ..Default::default()
423                });
424            }
425            heatmap.config = config;
426            let mut output = ctx.run_ui(
427                egui::RawInput {
428                    max_texture_side: Some(1024),
429                    ..Default::default()
430                },
431                |ui| {
432                    heatmap.render(
433                        ui.painter(),
434                        &mapping,
435                        &history,
436                        1.0,
437                        |t| Some((t - start).num_seconds() as f32 * 10.0),
438                        &OrderBookView::default(),
439                        [Color32::GREEN, Color32::RED],
440                    );
441                },
442            );
443            let uploads: Vec<_> = output
444                .textures_delta
445                .set
446                .iter()
447                .filter(|(id, _)| heatmap.tiles.values().any(|t| t.id() == **id))
448                .flat_map(|(_, deltas)| deltas)
449                .filter(|delta| delta.pos.is_some())
450                .collect();
451            if append == Some([999.0, 2000.0]) {
452                assert_eq!(uploads.len(), 1);
453                assert_eq!(uploads[0].pos, Some([1, 0]));
454            }
455            let alphas: Vec<_> = uploads
456                .iter()
457                .map(|delta| {
458                    assert_eq!(delta.image.size(), [1, 256]);
459                    let egui::ImageData::Color(image) = &delta.image;
460                    [image.pixels[100].a(), image.pixels[101].a()]
461                })
462                .collect();
463            output.textures_delta.clear();
464            assert_eq!(alphas, expected, "{config:?}");
465        }
466    }
467
468    /// Guards the marks: a cell takes the colour of the tier its largest recorded order reaches,
469    /// over its shading, while a row whose total alone reaches a tier and every row of aggregate
470    /// depth keep their shading, whatever the tiers. The default view takes the colours of the
471    /// painter's active theme. New tiers or a new theme redraw the held columns once, and
472    /// unchanged ones, even when they are not numbers, upload nothing.
473    #[test]
474    fn cells_whose_largest_order_reaches_a_tier_take_its_colour() {
475        let ctx = egui::Context::default();
476        let mut history = DepthHistory::new(Default::default()).unwrap();
477        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
478        let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
479        let mapping = ChartMapping::new(
480            rect,
481            10.0,
482            0,
483            10,
484            0.0,
485            PriceMap::new(
486                PriceTransform::default(),
487                PriceRange::new(95.0, 105.0),
488                0.0,
489                100.0,
490                false,
491            ),
492        );
493        let column = |second, largest_bid_orders, largest_ask_orders| DepthColumn {
494            time: start + TimeDelta::seconds(second),
495            span: TimeDelta::seconds(1),
496            bids: vec![(198, 115.0), (197, 25.0)],
497            asks: vec![(199, 30.0)],
498            largest_bid_orders,
499            largest_ask_orders,
500        };
501        // The first column knows its orders and the second is aggregate depth.
502        history.push(column(0, vec![50.0, 5.0], vec![30.0]));
503        history.push(column(1, vec![], vec![]));
504        let tiers = |notable, large| OrderBookView {
505            tiers: Some(crate::data::OrderSizeTiers { notable, large }),
506            ..Default::default()
507        };
508        let (dark, light) = (Theme::dark(), Theme::light());
509        let [dark_notable, dark_large] = [dark.chart.order_notable, dark.chart.order_large];
510        let [light_notable, light_large] = [light.chart.order_notable, light.chart.order_large];
511        // Rows 197, 198 and 199 against the largest total, 115: colour * 0.75 * ln(1 + total) /
512        // ln(1 + 115), so 255 * 0.75 * (3.2581 / 4.7536) = 131, 255 * 0.75 = 191 and
513        // 255 * 0.75 * (3.4340 / 4.7536) = 138.
514        let shaded = [(0, 131, 0, 131), (0, 191, 0, 191), (138, 0, 0, 138)]
515            .map(|(r, g, b, a)| Color32::from_rgba_premultiplied(r, g, b, a));
516        let mut heatmap = LiquidityHeatmap::default();
517        let defaults = LiquidityHeatmapConfig::default();
518        for (theme, view, config, expected) in [
519            (
520                &dark,
521                OrderBookView::default(),
522                defaults,
523                vec![shaded, shaded],
524            ),
525            // Row 197 stays shaded: its total of 25 reaches a tier and its largest order of 5 none.
526            (
527                &dark,
528                tiers(20.0, 40.0),
529                defaults,
530                vec![[shaded[0], dark_large, dark_notable], shaded],
531            ),
532            // The same tiers on another theme.
533            (
534                &light,
535                tiers(20.0, 40.0),
536                defaults,
537                vec![[shaded[0], light_large, light_notable], shaded],
538            ),
539            (
540                &dark,
541                tiers(0.0, 0.0),
542                defaults,
543                vec![[dark_large; 3], shaded],
544            ),
545            (
546                &dark,
547                tiers(f64::NAN, f64::NAN),
548                defaults,
549                vec![shaded, shaded],
550            ),
551            (&dark, tiers(f64::NAN, f64::NAN), defaults, vec![]),
552            (
553                &dark,
554                tiers(20.0, 40.0),
555                LiquidityHeatmapConfig {
556                    brightness: 0.5,
557                    ..defaults
558                },
559                vec![
560                    [shaded[0], dark_large, dark_notable].map(|c| c.gamma_multiply(0.5)),
561                    shaded.map(|c| c.gamma_multiply(0.5)),
562                ],
563            ),
564            (
565                &dark,
566                tiers(20.0, 40.0),
567                LiquidityHeatmapConfig {
568                    lower_cutoff: 115.0,
569                    ..defaults
570                },
571                vec![[Color32::TRANSPARENT; 3]; 2],
572            ),
573        ] {
574            crate::theme::apply_to_egui(&ctx, theme);
575            heatmap.config = config;
576            let mut output = ctx.run_ui(
577                egui::RawInput {
578                    max_texture_side: Some(1024),
579                    ..Default::default()
580                },
581                |ui| {
582                    heatmap.render(
583                        ui.painter(),
584                        &mapping,
585                        &history,
586                        0.5,
587                        |t| Some((t - start).num_seconds() as f32 * 10.0),
588                        &view,
589                        [Color32::GREEN, Color32::RED],
590                    );
591                },
592            );
593            let uploads: Vec<_> = output
594                .textures_delta
595                .set
596                .iter()
597                .filter(|(id, _)| heatmap.tiles.values().any(|t| t.id() == **id))
598                .flat_map(|(_, deltas)| deltas)
599                .filter(|delta| delta.pos.is_some())
600                .map(|delta| {
601                    let egui::ImageData::Color(image) = &delta.image;
602                    [image.pixels[197], image.pixels[198], image.pixels[199]]
603                })
604                .collect();
605            output.textures_delta.clear();
606            assert_eq!(uploads, expected);
607        }
608    }
609}