1use 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#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct LiquidityHeatmapConfig {
27 pub brightness: f32,
30 pub lower_cutoff: f64,
33 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#[derive(Default)]
78pub struct LiquidityHeatmap {
79 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 marks: Option<([u64; 2], [Color32; 2])>,
89}
90
91impl LiquidityHeatmap {
92 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 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 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 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 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 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 #[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 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 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 (
527 &dark,
528 tiers(20.0, 40.0),
529 defaults,
530 vec![[shaded[0], dark_large, dark_notable], shaded],
531 ),
532 (
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}