Skip to main content

egui_charts/chart/overlays/
volume_bubbles.rs

1//! # Volume Bubbles Overlay
2//!
3//! Draws the trades of the chart's series as bubbles, grouped as [`BubbleGrouping`] says, by
4//! default one per bar and price level, merging nearby screen cells when crowded. The diameter
5//! grows with volume within the available space, and the colour shows the aggressor side or
6//! the delta, as [`BubbleColor`] says.
7//! Turn them on with [`Chart::set_volume_bubbles`](crate::widget::Chart::set_volume_bubbles).
8
9use crate::model::{Bar, BarClock, OrderSide, Tick};
10use crate::tokens::DESIGN_TOKENS;
11use chrono::{DateTime, TimeDelta, Utc};
12use egui::{Color32, Mesh, Painter, Pos2, Shape, Vec2};
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, VecDeque};
15use std::sync::{Arc, Mutex};
16
17/// Configuration for volume bubbles overlay. Serializable, so a host can keep it with its chart
18/// settings; fields missing from older documents take their defaults.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(default)]
21pub struct VolumeBubblesConfig {
22    /// Minimum bubble diameter, reduced in crowded automatic views.
23    pub min_diameter: f32,
24    /// Maximum bubble diameter, limited by screen density with automatic grouping.
25    pub max_diameter: f32,
26    /// Opacity for bubbles
27    pub opacity: u8,
28    /// Color for buy trades
29    pub buy_color: Color32,
30    /// Color for sell trades
31    pub sell_color: Color32,
32    /// Color for bubbles without a side
33    pub neutral_color: Color32,
34    /// What the colour of a bubble shows
35    pub color_by: BubbleColor,
36    /// Size scaling mode
37    pub scaling_mode: BubbleScalingMode,
38    /// Volumes more than this many standard deviations above the mean of the visible bubbles
39    /// draw at the maximum diameter, so one large print does not shrink the rest. Infinity
40    /// turns the clamp off and is saved as `null` in JSON.
41    #[serde(deserialize_with = "null_as_infinity")]
42    pub clamp_std_devs: f64,
43    /// Show border around bubbles, fainter in crowded automatic views.
44    pub show_border: bool,
45    /// Border color
46    pub border_color: Color32,
47    /// Bubbles with less volume are neither drawn nor scaled over
48    pub min_size_filter: Option<f64>,
49    /// Which trades share a bubble.
50    pub grouping: BubbleGrouping,
51}
52
53// JSON writes infinity as null; retain the supported unclamped setting when loading it.
54fn null_as_infinity<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
55    Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::INFINITY))
56}
57
58impl Default for VolumeBubblesConfig {
59    fn default() -> Self {
60        Self {
61            min_diameter: DESIGN_TOKENS.spacing.sm,
62            max_diameter: DESIGN_TOKENS.sizing.charts_ext.volume_bubbles_max_diameter,
63            opacity: 180,
64            buy_color: DESIGN_TOKENS.semantic.chart.bullish,
65            sell_color: DESIGN_TOKENS.semantic.chart.bearish,
66            neutral_color: DESIGN_TOKENS.semantic.ui.text_muted_dark,
67            color_by: BubbleColor::Side,
68            scaling_mode: BubbleScalingMode::Logarithmic,
69            clamp_std_devs: 3.0,
70            show_border: true,
71            border_color: Color32::from_rgba_unmultiplied(255, 255, 255, 100),
72            min_size_filter: None,
73            grouping: BubbleGrouping::Automatic,
74        }
75    }
76}
77
78/// Which trades share a bubble. Each group is one bubble, whatever the chart's bar interval,
79/// except for [`Automatic`](Self::Automatic).
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81pub enum BubbleGrouping {
82    /// One bubble per bar and price level, the level being the instrument's tick size, or every
83    /// traded price when the feed reports none. Crowded views merge nearby bars and levels into
84    /// screen cells, retaining total volume and aggressor sides. Diameters leave space between
85    /// cells and crowded bubbles use fainter borders; spacious views keep the configured sizes.
86    Automatic,
87    /// One bubble per execution, at its own time and price, so fills of one price and instant
88    /// stay separate bubbles even when they land on one pixel. Draws every visible execution,
89    /// at most the chart's bounded trade store.
90    Ungrouped,
91    /// One bubble per price level and time window. Windows start on multiples of their length
92    /// counted from the Unix epoch, and a bubble sits in the middle of its window. A length
93    /// below a millisecond counts as one millisecond.
94    TimeWindow(TimeDelta),
95    /// One bubble per bar and price level of this height, which must be finite and positive,
96    /// else every traded price has its own bubble. Levels of a bar on one pixel row merge.
97    PriceStep(f64),
98    /// One bubble per this many consecutive executions, at the time of the last of them and the
99    /// volume-weighted price of all of them. The newest group grows until it is full. Groups
100    /// count from the oldest held execution, and a count of zero counts as one.
101    Executions(usize),
102}
103
104/// How to scale bubble sizes
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106pub enum BubbleScalingMode {
107    /// Linear scaling
108    Linear,
109    /// Logarithmic scaling (better for large range)
110    Logarithmic,
111    /// Square root scaling
112    SquareRoot,
113}
114
115/// What the colour of a bubble shows.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117pub enum BubbleColor {
118    /// The aggressor side with more volume, or the neutral colour on a tie and when no trade
119    /// has a side.
120    Side,
121    /// The delta as a share of the volume with a side: the sell colour when every trade sold,
122    /// the buy colour when every trade bought, and the blend between them in between.
123    Delta,
124    /// Two circles on one centre, each sized by its own side's volume, so the smaller one shows
125    /// inside the larger: the sell colour for the bid side, the buy colour for the ask side.
126    /// Volume of unknown side is left out, and a bubble without any volume with a side is one
127    /// neutral circle.
128    Split,
129    /// The sign of the delta picks the buy or sell colour, and its absolute size, relative to
130    /// the largest absolute delta of the visible bubbles, the opacity, down to a fifth of it.
131    /// A zero delta, and a bubble without volume with a side, take the neutral colour.
132    AbsoluteDelta,
133    /// The delta as a share of the whole volume, unknown side included: the neutral colour
134    /// when it is zero, fading to the buy or sell colour as the share nears 100%, so volume of
135    /// unknown side pulls the colour toward neutral.
136    DeltaPercent,
137    /// The neutral colour for every bubble: the diameter alone shows the volume.
138    Volume,
139}
140
141/// The trades of one bubble.
142#[derive(Clone, Debug, Default, PartialEq)]
143pub(crate) struct Bubble {
144    /// Open time of the bar, middle of the window, or time of the execution or of the last one.
145    pub(crate) at: DateTime<Utc>,
146    /// The price level, or the traded price without a step.
147    pub(crate) price: f64,
148    /// Volume of the trades that bought.
149    pub(crate) buy: f64,
150    /// Volume of the trades that sold.
151    pub(crate) sell: f64,
152    /// Volume of every trade, with those of unknown side.
153    pub(crate) volume: f64,
154    pub(crate) trades: usize,
155    /// Trades that bought, so a side left without trades shows no rounding residue.
156    pub(crate) buys: usize,
157    /// Trades that sold.
158    pub(crate) sells: usize,
159}
160
161impl Bubble {
162    /// Adds the trades of `other`, a bubble drawn at the same place.
163    fn merge(&mut self, other: &Bubble) {
164        self.buy += other.buy;
165        self.sell += other.sell;
166        self.volume += other.volume;
167        self.trades += other.trades;
168        self.buys += other.buys;
169        self.sells += other.sells;
170    }
171
172    /// The bubble of consecutive executions: at the time of the last, at their volume-weighted
173    /// price, or the last price when none has volume.
174    fn of_group<'a>(trades: impl Iterator<Item = &'a Tick>) -> Self {
175        let mut weighted = 0.0;
176        let mut group = Self::default();
177        for trade in trades {
178            weighted += trade.price * trade.size;
179            group.merge(&Self::of(trade));
180            group.at = trade.ts;
181            group.price = trade.price;
182        }
183        if group.volume > 0.0 {
184            group.price = weighted / group.volume;
185        }
186        group
187    }
188
189    /// The bubble of one execution.
190    fn of(trade: &Tick) -> Self {
191        let (buy, sell) = match trade.side {
192            Some(OrderSide::Buy) => (trade.size, 0.0),
193            Some(OrderSide::Sell) => (0.0, trade.size),
194            None => (0.0, 0.0),
195        };
196        Self {
197            at: trade.ts,
198            price: trade.price,
199            buy,
200            sell,
201            volume: trade.size,
202            trades: 1,
203            buys: usize::from(trade.side == Some(OrderSide::Buy)),
204            sells: usize::from(trade.side == Some(OrderSide::Sell)),
205        }
206    }
207}
208
209/// Counts a trade in (`added`) or out, `count` being the trades whose volume `sum` holds.
210fn count(count: &mut usize, sum: &mut f64, size: f64, added: bool) {
211    *count = match added {
212        true => *count + 1,
213        false => count.saturating_sub(1),
214    };
215    *sum = if *count == 0 { 0.0 } else { *sum + size };
216}
217
218/// The trades the chart holds, summed as the grouping says, and how to draw them.
219pub(crate) struct VolumeBubbles {
220    pub(crate) config: VolumeBubblesConfig,
221    clock: BarClock,
222    step: Option<f64>,
223    /// Milliseconds of a time window, for [`BubbleGrouping::TimeWindow`].
224    window: i64,
225    /// Executions of a group, for [`BubbleGrouping::Executions`].
226    group: usize,
227    /// By bar open time or window start, then price level, so the bubbles of a run of bars are
228    /// one range. Empty for the groupings that keep the trades.
229    bubbles: BTreeMap<(DateTime<Utc>, i64), Bubble>,
230    /// The held trades by time, for the groupings that keep them: those of the chart's bounded
231    /// trade store, which says when one is evicted.
232    trades: VecDeque<Tick>,
233    /// Trades evicted from the front of `trades`, so groups of executions keep their edges as
234    /// the oldest trades go.
235    evicted: usize,
236    /// The mesh of the last frame, refilled in place once the tessellator has let go of it.
237    mesh: Mutex<Arc<Mesh>>,
238}
239
240impl VolumeBubbles {
241    /// Sums `trades` as the configured grouping says, a price level being the configured step
242    /// high, else `tick_size`.
243    pub(crate) fn new<'a>(
244        config: VolumeBubblesConfig,
245        clock: BarClock,
246        tick_size: Option<f64>,
247        trades: impl IntoIterator<Item = &'a Tick>,
248    ) -> Self {
249        let step = match config.grouping {
250            BubbleGrouping::PriceStep(step) => Some(step),
251            _ => tick_size,
252        }
253        .filter(|step| step.is_finite() && *step > 0.0);
254        let window = match config.grouping {
255            BubbleGrouping::TimeWindow(window) => window.num_milliseconds().max(1),
256            _ => 1,
257        };
258        let group = match config.grouping {
259            BubbleGrouping::Executions(count) => count.max(1),
260            _ => 1,
261        };
262        let mut bubbles = Self {
263            config,
264            clock,
265            step,
266            window,
267            group,
268            bubbles: BTreeMap::new(),
269            trades: VecDeque::new(),
270            evicted: 0,
271            mesh: Mutex::default(),
272        };
273        for trade in trades {
274            bubbles.fold(trade, true);
275        }
276        bubbles
277    }
278
279    /// Adds a trade the chart took (`added`), or takes out one it dropped.
280    pub(crate) fn fold(&mut self, trade: &Tick, added: bool) {
281        if matches!(
282            self.config.grouping,
283            BubbleGrouping::Ungrouped | BubbleGrouping::Executions(_)
284        ) {
285            self.fold_trade(trade, added);
286            return;
287        }
288        let (level, price) = match self.step {
289            Some(step) => {
290                let level = (trade.price / step).round();
291                (level as i64, level * step)
292            }
293            // Trades are finite, so equal keys are equal prices; the flip orders negative prices
294            // as `f64::total_cmp` does.
295            None => {
296                let bits = trade.price.to_bits() as i64;
297                (bits ^ (((bits >> 63) as u64) >> 1) as i64, trade.price)
298            }
299        };
300        let (open, at) = match self.config.grouping {
301            BubbleGrouping::TimeWindow(_) => {
302                let millis = trade.ts.timestamp_millis();
303                let start = millis - millis.rem_euclid(self.window);
304                let start = DateTime::from_timestamp_millis(start).unwrap_or(trade.ts);
305                (start, start + TimeDelta::milliseconds(self.window / 2))
306            }
307            _ => {
308                let bar = self.clock.bar_open(trade.ts);
309                (bar, bar)
310            }
311        };
312        let key = (open, level);
313        let bubble = self.bubbles.entry(key).or_insert_with(|| Bubble {
314            at,
315            price,
316            ..Bubble::default()
317        });
318        let size = if added { trade.size } else { -trade.size };
319        count(&mut bubble.trades, &mut bubble.volume, size, added);
320        match trade.side {
321            Some(OrderSide::Buy) => count(&mut bubble.buys, &mut bubble.buy, size, added),
322            Some(OrderSide::Sell) => count(&mut bubble.sells, &mut bubble.sell, size, added),
323            None => {}
324        }
325        if bubble.trades == 0 {
326            self.bubbles.remove(&key);
327        }
328    }
329
330    /// Keeps `trade` in time order, after those at the same instant, or drops one equal to it.
331    fn fold_trade(&mut self, trade: &Tick, added: bool) {
332        if added {
333            let at = self.trades.partition_point(|held| held.ts <= trade.ts);
334            self.trades.insert(at, trade.clone());
335            return;
336        }
337        let first = self.trades.partition_point(|held| held.ts < trade.ts);
338        let end = self.trades.partition_point(|held| held.ts <= trade.ts);
339        if let Some(at) = (first..end).find(|&at| self.trades[at] == *trade) {
340            self.trades.remove(at);
341            self.evicted += usize::from(at == 0);
342        }
343    }
344
345    /// The bubbles from the bars that open from `first` to `last`, in time order, by price level
346    /// within a bar. With a time window, those of the windows that start from a window before
347    /// `first`; with executions kept, those of the executions at those times, and of the groups
348    /// they belong to.
349    pub(crate) fn between(
350        &self,
351        first: DateTime<Utc>,
352        last: DateTime<Utc>,
353    ) -> impl Iterator<Item = Bubble> {
354        let last = last.max(first);
355        let mut shown = Vec::new();
356        match self.config.grouping {
357            BubbleGrouping::Ungrouped => {
358                let from = self.trades.partition_point(|t| t.ts < first);
359                shown.extend(
360                    self.trades
361                        .iter()
362                        .skip(from)
363                        .take_while(|t| t.ts <= last)
364                        .map(Bubble::of),
365                );
366            }
367            BubbleGrouping::Executions(_) => {
368                let from = self.trades.partition_point(|t| t.ts < first);
369                let to = self.trades.partition_point(|t| t.ts <= last);
370                // Whole groups: from the edge at or before the first trade to the one after the
371                // last.
372                let mut at = from - ((self.evicted + from) % self.group).min(from);
373                let end = to + (self.group - (self.evicted + to) % self.group) % self.group;
374                while from < to && at < end.min(self.trades.len()) {
375                    let next =
376                        (at + self.group - (self.evicted + at) % self.group).min(self.trades.len());
377                    shown.push(Bubble::of_group(self.trades.range(at..next)));
378                    at = next;
379                }
380            }
381            _ => {
382                let lower = match self.config.grouping {
383                    BubbleGrouping::TimeWindow(_) => first - TimeDelta::milliseconds(self.window),
384                    _ => first,
385                };
386                let range = (lower, i64::MIN)..=(last, i64::MAX);
387                shown.extend(self.bubbles.range(range).map(|(_, bubble)| bubble.clone()));
388            }
389        }
390        shown.into_iter()
391    }
392
393    /// Draws the bubbles of `bars`, the visible bars, the `i`th bar's at `x(i)` and `spacing`
394    /// apart. Bubbles that do not sit on a bar's centre are placed in its slot by their time.
395    pub(crate) fn render(
396        &self,
397        painter: &Painter,
398        bars: &[Bar],
399        spacing: f32,
400        x: impl Fn(usize) -> f32,
401        price_to_y: impl Fn(f64) -> f32,
402    ) {
403        if bars.is_empty() {
404            return;
405        }
406        let slot = self.clock.timeframe().map_or(0, |t| t.duration_ms());
407        // The clock walks a calendar for each call, so each bar's open is asked once.
408        let opens: Vec<_> = bars
409            .iter()
410            .map(|bar| self.clock.bar_open(bar.time))
411            .collect();
412        let open_of = |i: usize| opens[i];
413        let slot_of = |i: usize| match bars.get(i + 1) {
414            Some(_) if slot > 0 => slot.min((open_of(i + 1) - open_of(i)).num_milliseconds()),
415            Some(_) => (open_of(i + 1) - open_of(i)).num_milliseconds(),
416            None => slot,
417        };
418        let end = opens[opens.len() - 1] + TimeDelta::milliseconds(slot_of(bars.len() - 1));
419        let min_size = self.config.min_size_filter.unwrap_or(f64::NEG_INFINITY);
420        let mut shown: Vec<(f32, f32, Bubble)> = Vec::new();
421        for bubble in self.between(opens[0], end) {
422            let i = opens.partition_point(|open| *open <= bubble.at);
423            let Some(i) = i.checked_sub(1) else { continue };
424            let elapsed = (bubble.at - open_of(i)).num_milliseconds();
425            let (x, merges) = match self.config.grouping {
426                BubbleGrouping::Automatic | BubbleGrouping::PriceStep(_) => {
427                    if open_of(i) != bubble.at {
428                        continue;
429                    }
430                    (x(i), true)
431                }
432                _ if elapsed < slot_of(i) => {
433                    let share = elapsed as f32 / slot_of(i) as f32;
434                    (x(i) + (share - 0.5) * spacing, false)
435                }
436                _ if slot_of(i) == 0 && elapsed == 0 => (x(i), false),
437                _ => continue,
438            };
439            // A bar's levels come in price order, so those on one row are adjacent.
440            let y = price_to_y(bubble.price).round();
441            match shown.last_mut() {
442                Some((at, row, merged)) if merges && (*at, *row) == (x, y) => merged.merge(&bubble),
443                _ => shown.push((x, y, bubble)),
444            }
445        }
446        let max_diameter = if self.config.grouping == BubbleGrouping::Automatic {
447            group_on_screen(&mut shown, spacing).min(self.config.max_diameter)
448        } else {
449            self.config.max_diameter
450        };
451        shown.retain(|(_, _, bubble)| bubble.volume >= min_size);
452        let mut scale = Scale::new(
453            &self.config,
454            shown.iter().flat_map(|(_, _, b)| {
455                let (discs, n) = self.discs(b, 0.0);
456                discs.into_iter().take(n).map(|(volume, _)| volume)
457            }),
458        );
459        scale.max_diameter = max_diameter;
460        if max_diameter < self.config.max_diameter {
461            scale.min_diameter = scale.min_diameter.min(max_diameter / 4.0);
462        }
463        let max_delta = shown
464            .iter()
465            .map(|(_, _, b)| (b.buy - b.sell).abs())
466            .fold(0.0, f64::max);
467        let border = self.config.show_border.then(|| {
468            if max_diameter < self.config.max_diameter {
469                self.config.border_color.gamma_multiply(0.5)
470            } else {
471                self.config.border_color
472            }
473        });
474        // One mesh for every bubble: the tessellator takes far longer over a circle shape per
475        // bubble than over copying these vertices, and a fresh mesh of this size every frame
476        // costs about as much again in page faults.
477        let mut last = self.mesh.lock().unwrap_or_else(|e| e.into_inner());
478        let mut mesh = Arc::try_unwrap(std::mem::take(&mut *last)).unwrap_or_default();
479        mesh.clear();
480        for (x, y, bubble) in &shown {
481            let center = Pos2::new(*x, *y);
482            let (discs, n) = self.discs(bubble, max_delta);
483            for &(volume, color) in &discs[..n] {
484                let radius = scale.diameter(volume) / 2.0;
485                add_disc(
486                    &mut mesh,
487                    center,
488                    radius,
489                    color,
490                    border,
491                    painter.pixels_per_point(),
492                );
493            }
494        }
495        *last = Arc::new(mesh);
496        painter.add(Shape::Mesh(Arc::clone(&last)));
497    }
498
499    /// The circles of a bubble, the largest first, as the volume that sizes each and its colour,
500    /// and how many of the two there are.
501    fn discs(&self, bubble: &Bubble, max_delta: f64) -> ([(f64, Color32); 2], usize) {
502        let config = &self.config;
503        let mut discs = [(bubble.volume, self.color(bubble, max_delta)); 2];
504        if config.color_by != BubbleColor::Split || bubble.buy + bubble.sell <= 0.0 {
505            return (discs, 1);
506        }
507        let mut sides = [
508            (bubble.sell, bubble.sells, config.sell_color),
509            (bubble.buy, bubble.buys, config.buy_color),
510        ];
511        sides.sort_by(|a, b| b.0.total_cmp(&a.0));
512        let mut n = 0;
513        for (volume, trades, color) in sides {
514            if trades > 0 {
515                discs[n] = (volume, with_opacity(color, config.opacity));
516                n += 1;
517            }
518        }
519        (discs, n)
520    }
521
522    fn color(&self, bubble: &Bubble, max_delta: f64) -> Color32 {
523        let config = &self.config;
524        let sided = bubble.buy + bubble.sell;
525        let delta = bubble.buy - bubble.sell;
526        let mut opacity = config.opacity;
527        let base = match config.color_by {
528            BubbleColor::Volume => config.neutral_color,
529            BubbleColor::DeltaPercent if bubble.volume > 0.0 => {
530                let share = (delta.abs() / bubble.volume).min(1.0) as f32;
531                let side = if delta >= 0.0 {
532                    config.buy_color
533                } else {
534                    config.sell_color
535                };
536                config.neutral_color.lerp_to_gamma(side, share)
537            }
538            _ if bubble.buys + bubble.sells == 0 || sided <= 0.0 => config.neutral_color,
539            BubbleColor::Side if bubble.buy > bubble.sell => config.buy_color,
540            BubbleColor::Side if bubble.sell > bubble.buy => config.sell_color,
541            BubbleColor::Delta => {
542                let buy_share = (bubble.buy / sided) as f32;
543                config.sell_color.lerp_to_gamma(config.buy_color, buy_share)
544            }
545            BubbleColor::AbsoluteDelta if delta != 0.0 => {
546                let share = (delta.abs() / max_delta).min(1.0) as f32;
547                opacity = (f32::from(opacity) * (0.2 + 0.8 * share)).round() as u8;
548                if delta > 0.0 {
549                    config.buy_color
550                } else {
551                    config.sell_color
552                }
553            }
554            _ => config.neutral_color,
555        };
556        with_opacity(base, opacity)
557    }
558}
559
560/// Merges crowded automatic bubbles on a 12-point grid and returns the diameter that leaves a
561/// quarter of the available spacing clear. Sparse bars and levels keep their exact positions.
562fn group_on_screen(shown: &mut Vec<(f32, f32, Bubble)>, spacing: f32) -> f32 {
563    let gap = shown
564        .windows(2)
565        .filter(|pair| pair[0].0 == pair[1].0)
566        .map(|pair| (pair[0].1 - pair[1].1).abs())
567        .fold(f32::INFINITY, f32::min);
568    let width = spacing.max(12.0);
569    let merge_x = spacing < 12.0;
570    let merge_y = gap < 12.0 || merge_x;
571    if merge_y {
572        let mut cells: BTreeMap<_, (f32, f32, Bubble)> = BTreeMap::new();
573        for (mut x, mut y, bubble) in shown.drain(..) {
574            if merge_x {
575                x = ((x / width).floor() + 0.5) * width;
576            }
577            y = (y / 12.0).round() * 12.0;
578            cells
579                .entry((x.to_bits(), y.to_bits()))
580                .and_modify(|(_, _, merged)| merged.merge(&bubble))
581                .or_insert((x, y, bubble));
582        }
583        shown.extend(cells.into_values());
584    }
585    0.75 * width.min(if merge_y { 12.0 } else { gap })
586}
587
588fn with_opacity(color: Color32, opacity: u8) -> Color32 {
589    Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), opacity)
590}
591
592/// Appends a disc of `fill` to `mesh`, ringed outside by a hairline of `border`, with edges
593/// faded over one physical pixel as the tessellator fades them. The polygon has the fewest sides
594/// that keep it within a quarter of a physical pixel of the circle at `pixels_per_point`.
595fn add_disc(
596    mesh: &mut Mesh,
597    center: Pos2,
598    radius: f32,
599    fill: Color32,
600    border: Option<Color32>,
601    pixels_per_point: f32,
602) {
603    let half = 0.5 / pixels_per_point;
604    let inner = (radius - half).max(0.0);
605    let width = DESIGN_TOKENS.stroke.hairline;
606    // Ring radii and colours from the centre out; a hairline no wider than the fade peaks once.
607    let rings: &[(f32, Color32)] = match border {
608        None => &[(inner, fill), (radius + half, Color32::TRANSPARENT)],
609        Some(border) if width <= 2.0 * half => &[
610            (inner, fill),
611            (radius + half, border),
612            (radius + width + half, Color32::TRANSPARENT),
613        ],
614        Some(border) => &[
615            (inner, fill),
616            (radius + half, border),
617            (radius + width - half, border),
618            (radius + width + half, Color32::TRANSPARENT),
619        ],
620    };
621    let outer = rings[rings.len() - 1].0 * pixels_per_point;
622    let max_step = (1.0 - 0.25 / outer).clamp(-1.0, 1.0).acos();
623    let sides = ((std::f32::consts::PI / max_step).ceil() as u32).clamp(8, 256);
624    let (sin, cos) = (std::f32::consts::TAU / sides as f32).sin_cos();
625    let per_side = rings.len() as u32;
626    let first = mesh.vertices.len() as u32;
627    mesh.reserve_vertices(1 + (sides * per_side) as usize);
628    mesh.reserve_triangles((sides * (2 * per_side - 1)) as usize);
629    mesh.colored_vertex(center, fill);
630    let mut direction = Vec2::X;
631    for side in 0..sides {
632        for &(r, color) in rings {
633            mesh.colored_vertex(center + r * direction, color);
634        }
635        direction = Vec2::new(
636            direction.x * cos - direction.y * sin,
637            direction.x * sin + direction.y * cos,
638        );
639        let a = first + 1 + side * per_side;
640        let b = first + 1 + (side + 1) % sides * per_side;
641        mesh.add_triangle(first, a, b);
642        for ring in 0..per_side - 1 {
643            mesh.add_triangle(a + ring, b + ring, a + ring + 1);
644            mesh.add_triangle(b + ring, b + ring + 1, a + ring + 1);
645        }
646    }
647}
648
649/// Diameters for the volumes of the bubbles drawn together.
650struct Scale {
651    low: f64,
652    high: f64,
653    mode: BubbleScalingMode,
654    min_diameter: f32,
655    max_diameter: f32,
656}
657
658impl Scale {
659    /// Spans the diameters over `volumes`, the largest clamped to the configured number of
660    /// standard deviations above their mean.
661    fn new(config: &VolumeBubblesConfig, volumes: impl Iterator<Item = f64> + Clone) -> Self {
662        let count = volumes.clone().count().max(1) as f64;
663        let mean = volumes.clone().sum::<f64>() / count;
664        let variance = volumes.clone().map(|v| (v - mean).powi(2)).sum::<f64>() / count;
665        let cap = mean + config.clamp_std_devs * variance.sqrt();
666        let low = volumes.clone().fold(f64::INFINITY, f64::min);
667        let high = volumes.fold(f64::NEG_INFINITY, f64::max).min(cap);
668        Self {
669            low,
670            high,
671            mode: config.scaling_mode,
672            min_diameter: config.min_diameter,
673            max_diameter: config.max_diameter,
674        }
675    }
676
677    fn diameter(&self, volume: f64) -> f32 {
678        let scaled = |volume: f64| match self.mode {
679            BubbleScalingMode::Linear => volume,
680            BubbleScalingMode::Logarithmic => volume.max(0.0).ln_1p(),
681            BubbleScalingMode::SquareRoot => volume.max(0.0).sqrt(),
682        };
683        let (low, high) = (scaled(self.low), scaled(self.high));
684        if high <= low {
685            return (self.min_diameter + self.max_diameter) / 2.0;
686        }
687        let share = ((scaled(volume.min(self.high)) - low) / (high - low)).clamp(0.0, 1.0);
688        self.min_diameter + share as f32 * (self.max_diameter - self.min_diameter)
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::data::trades::Trades;
696    use crate::model::Timeframe;
697    use BubbleGrouping::{Automatic, Executions, PriceStep, TimeWindow, Ungrouped};
698
699    /// A trade `second` seconds after 10:00.
700    fn trade(second: i64, price: f64, size: f64, side: Option<OrderSide>) -> Tick {
701        let ts = "2024-03-15T10:00:00Z".parse::<DateTime<Utc>>().unwrap();
702        Tick::new(ts + TimeDelta::seconds(second), price, size, side)
703    }
704
705    /// Guards the aggregation in data space and its bound: trades of one one-minute bar at one
706    /// price level make one bubble wherever they sit on screen, a bubble is stamped with its
707    /// bar, the level is the configured step else the tick size, and a trade the store evicts
708    /// leaves its bubble, so the bubbles never hold more than the bounded store, nor a rounding
709    /// residue on a side whose trades all left.
710    #[test]
711    fn bubbles_sum_the_held_trades_per_bar_and_price_level() {
712        let (buy, sell) = (Some(OrderSide::Buy), Some(OrderSide::Sell));
713        let ten = "2024-03-15T10:00:00Z".parse::<DateTime<Utc>>().unwrap();
714        let ten_one = ten + TimeDelta::minutes(1);
715        #[rustfmt::skip]
716        let cases = [
717            ("a level and a bar make one bubble", Automatic, Some(0.5), 10,
718                vec![trade(1, 100.1, 2.0, buy), trade(50, 99.9, 3.0, sell), trade(59, 100.2, 1.0, None),
719                    trade(61, 100.0, 4.0, buy)],
720                vec![(ten, 100.0, 2.0, 3.0, 6.0, 3, 1, 1), (ten_one, 100.0, 4.0, 0.0, 4.0, 1, 1, 0)]),
721            ("the configured step wins over the tick size", PriceStep(1.0), Some(0.5), 10,
722                vec![trade(1, 100.4, 1.0, buy), trade(2, 99.6, 1.0, buy), trade(3, 101.0, 1.0, sell)],
723                vec![(ten, 100.0, 2.0, 0.0, 2.0, 2, 2, 0), (ten, 101.0, 0.0, 1.0, 1.0, 1, 0, 1)]),
724            ("without a step every price has its own bubble", Automatic, None, 10,
725                vec![trade(1, 100.1, 1.0, buy), trade(2, 100.2, 1.0, buy), trade(3, 100.1, 2.0, buy)],
726                vec![(ten, 100.1, 3.0, 0.0, 3.0, 2, 2, 0), (ten, 100.2, 1.0, 0.0, 1.0, 1, 1, 0)]),
727            ("an evicted trade leaves its bubble", Automatic, Some(1.0), 2,
728                vec![trade(1, 100.0, 1.0, buy), trade(61, 100.0, 2.0, sell), trade(62, 101.0, 5.0, buy)],
729                vec![(ten_one, 100.0, 0.0, 2.0, 2.0, 1, 0, 1), (ten_one, 101.0, 5.0, 0.0, 5.0, 1, 1, 0)]),
730            ("a side whose trades were all evicted holds no volume", Automatic, Some(1.0), 1,
731                vec![trade(1, 100.0, 0.1, buy), trade(2, 100.0, 0.2, buy), trade(3, 100.0, 1.0, None)],
732                vec![(ten, 100.0, 0.0, 0.0, 1.0, 1, 0, 0)]),
733        ];
734        for (name, grouping, tick_size, capacity, trades, expected) in cases {
735            let config = VolumeBubblesConfig {
736                grouping,
737                ..VolumeBubblesConfig::default()
738            };
739            let clock = BarClock::new(Timeframe::Min1);
740            let mut bubbles = VolumeBubbles::new(config, clock, tick_size, []);
741            let mut store = Trades::new(capacity);
742            for trade in trades {
743                let mut pending = store.empty();
744                pending.push(trade);
745                store.append(pending, &mut |trade, added| bubbles.fold(trade, added));
746            }
747            let got = Vec::from_iter((bubbles.between(ten, ten_one)).map(|b| {
748                (
749                    b.at, b.price, b.buy, b.sell, b.volume, b.trades, b.buys, b.sells,
750                )
751            }));
752            assert_eq!(got, expected, "{name}");
753        }
754    }
755
756    /// Guards the shape of a bubble at 1x and 2x: every side stays within a quarter of a physical
757    /// pixel of the circle, the fill ends half a pixel inside the radius, and the edge fades to
758    /// nothing half a pixel past the radius, or past the hairline outside it with a border. Too
759    /// few sides, a rotation that drifts or a ring at the wrong radius fails it.
760    #[test]
761    fn bubbles_stay_round_and_fade_over_one_pixel() {
762        let (fill, border) = (Color32::RED, Color32::WHITE);
763        for (radius, ppp, with_border) in [
764            (2.0, 1.0, false),
765            (2.0, 2.0, true),
766            (12.0, 1.0, true),
767            (20.0, 1.0, false),
768            (20.0, 2.0, true),
769        ] {
770            let half = 0.5 / ppp;
771            let rings = match (with_border, ppp) {
772                (false, _) => vec![(radius - half, fill), (radius + half, Color32::TRANSPARENT)],
773                (true, 1.0) => vec![
774                    (radius - half, fill),
775                    (radius + half, border),
776                    (radius + 1.0 + half, Color32::TRANSPARENT),
777                ],
778                (true, _) => vec![
779                    (radius - half, fill),
780                    (radius + half, border),
781                    (radius + 1.0 - half, border),
782                    (radius + 1.0 + half, Color32::TRANSPARENT),
783                ],
784            };
785            let center = Pos2::new(100.0, 50.0);
786            let mut mesh = Mesh::default();
787            add_disc(
788                &mut mesh,
789                center,
790                radius,
791                fill,
792                with_border.then_some(border),
793                ppp,
794            );
795            let case = format!("radius {radius} at {ppp}x, border {with_border}");
796            assert!(mesh.is_valid(), "{case}");
797            let sides = (mesh.vertices.len() - 1) / rings.len();
798            assert_eq!(
799                mesh.indices.len(),
800                3 * sides * (2 * rings.len() - 1),
801                "{case}"
802            );
803            for (ring, (r, color)) in rings.iter().enumerate() {
804                let at = |side: usize| mesh.vertices[1 + side % sides * rings.len() + ring];
805                for side in 0..sides {
806                    let (a, b) = (at(side), at(side + 1));
807                    assert!(((a.pos - center).length() - r).abs() < 1e-3, "{case}");
808                    assert_eq!(a.color, *color, "{case}");
809                    let middle = (a.pos + b.pos.to_vec2()) / 2.0;
810                    let error = (r - (middle - center).length()) * ppp;
811                    assert!((0.0..=0.25).contains(&error), "{case}: {error} px");
812                }
813            }
814        }
815    }
816
817    /// Guards the clamp of outliers: the sizes span the bubbles drawn together, and a volume
818    /// beyond the mean plus `clamp_std_devs` standard deviations draws at the maximum diameter
819    /// without shrinking the others. Expected values from the population formula computed
820    /// independently.
821    #[test]
822    fn a_large_print_does_not_shrink_the_other_bubbles() {
823        let volumes = [[10.0; 9].as_slice(), &[20.0, 1000.0]].concat();
824        let config = |clamp_std_devs| VolumeBubblesConfig {
825            min_diameter: 0.0,
826            max_diameter: 100.0,
827            scaling_mode: BubbleScalingMode::Linear,
828            clamp_std_devs,
829            ..VolumeBubblesConfig::default()
830        };
831        for (clamp, expected) in [
832            (f64::INFINITY, [0.0, 1.010_101, 100.0]),
833            (1.0, [0.0, 2.664_954, 100.0]),
834        ] {
835            let scale = Scale::new(&config(clamp), volumes.iter().copied());
836            let got = [10.0, 20.0, 1000.0].map(|volume| scale.diameter(volume));
837            for (got, expected) in got.into_iter().zip(expected) {
838                assert!(
839                    (got - expected).abs() < 1e-4,
840                    "clamp {clamp}: {got} vs {expected}"
841                );
842            }
843        }
844    }
845
846    /// Guards the groupings that do not follow the bar interval: fills of one instant and price
847    /// stay separate when ungrouped, a time window and a price level make one bubble, and groups
848    /// of executions keep their edges when the store evicts the oldest trade, so a bubble does
849    /// not change shape as the tape moves. Expected values worked out by hand: `(seconds after
850    /// 10:00, price, buy, sell, volume)`.
851    #[test]
852    fn groupings_other_than_the_bar_make_the_expected_bubbles() {
853        let (buy, sell) = (Some(OrderSide::Buy), Some(OrderSide::Sell));
854        let ten = "2024-03-15T10:00:00Z".parse::<DateTime<Utc>>().unwrap();
855        let tape = || {
856            vec![
857                trade(1, 100.0, 2.0, buy),
858                trade(2, 102.0, 2.0, sell),
859                trade(2, 102.0, 1.0, None),
860                trade(12, 100.0, 3.0, sell),
861            ]
862        };
863        #[rustfmt::skip]
864        let cases = [
865            ("ungrouped keeps fills of one instant and price apart", Ungrouped, 10,
866                vec![(1, 100.0, 2.0, 0.0, 2.0), (2, 102.0, 0.0, 2.0, 2.0),
867                    (2, 102.0, 0.0, 0.0, 1.0), (12, 100.0, 0.0, 3.0, 3.0)]),
868            ("a time window and a level make one bubble, in the middle of the window", TimeWindow(TimeDelta::seconds(10)), 10,
869                vec![(5, 100.0, 2.0, 0.0, 2.0), (5, 102.0, 0.0, 2.0, 3.0), (15, 100.0, 0.0, 3.0, 3.0)]),
870            ("executions group at the volume weighted price and the last time", Executions(2), 10,
871                vec![(2, 101.0, 2.0, 2.0, 4.0), (12, 100.5, 0.0, 3.0, 4.0)]),
872            ("an eviction leaves the other groups as they were", Executions(2), 3,
873                vec![(2, 102.0, 0.0, 2.0, 2.0), (12, 100.5, 0.0, 3.0, 4.0)]),
874        ];
875        for (name, grouping, capacity, expected) in cases {
876            let config = VolumeBubblesConfig {
877                grouping,
878                ..VolumeBubblesConfig::default()
879            };
880            let clock = BarClock::new(Timeframe::Min1);
881            let mut bubbles = VolumeBubbles::new(config, clock, Some(1.0), []);
882            let mut store = Trades::new(capacity);
883            for trade in tape() {
884                let mut pending = store.empty();
885                pending.push(trade);
886                store.append(pending, &mut |trade, added| bubbles.fold(trade, added));
887            }
888            let got = Vec::from_iter(
889                bubbles
890                    .between(ten, ten + TimeDelta::minutes(1))
891                    .map(|b| ((b.at - ten).num_seconds(), b.price, b.buy, b.sell, b.volume)),
892            );
893            assert_eq!(got, expected, "{name}");
894        }
895    }
896
897    /// Guards the colour rules: the delta as a share of the volume with a side differs from the
898    /// share of all volume, which unknown-side trades pull toward neutral; the absolute delta
899    /// fades against the largest visible one; split shows both sides in circles of their own
900    /// volume, the larger first; plain volume ignores the side.
901    #[test]
902    fn each_colour_mode_colours_the_bubble_as_documented() {
903        let config = |color_by| VolumeBubblesConfig {
904            color_by,
905            opacity: 200,
906            buy_color: Color32::from_rgb(0, 255, 0),
907            sell_color: Color32::from_rgb(255, 0, 0),
908            neutral_color: Color32::from_rgb(100, 100, 100),
909            ..VolumeBubblesConfig::default()
910        };
911        let bubble = |buy: f64, sell: f64, unknown: f64| Bubble {
912            buy,
913            sell,
914            volume: buy + sell + unknown,
915            buys: usize::from(buy > 0.0),
916            sells: usize::from(sell > 0.0),
917            trades: 1,
918            ..Bubble::default()
919        };
920        let with = |c: Color32, a: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a);
921        let (green, red, gray) = (
922            Color32::from_rgb(0, 255, 0),
923            Color32::from_rgb(255, 0, 0),
924            Color32::from_rgb(100, 100, 100),
925        );
926        #[rustfmt::skip]
927        let cases = [
928            (BubbleColor::Delta, bubble(2.0, 0.0, 2.0), 4.0, vec![(4.0, with(green, 200))]),
929            (BubbleColor::DeltaPercent, bubble(2.0, 0.0, 2.0), 4.0, vec![(4.0, with(Color32::from_rgb(50, 178, 50), 200))]),
930            (BubbleColor::DeltaPercent, bubble(0.0, 3.0, 0.0), 4.0, vec![(3.0, with(red, 200))]),
931            (BubbleColor::DeltaPercent, bubble(0.0, 0.0, 5.0), 4.0, vec![(5.0, with(gray, 200))]),
932            (BubbleColor::AbsoluteDelta, bubble(4.0, 0.0, 1.0), 4.0, vec![(5.0, with(green, 200))]),
933            (BubbleColor::AbsoluteDelta, bubble(0.0, 1.0, 0.0), 4.0, vec![(1.0, with(red, 80))]),
934            (BubbleColor::AbsoluteDelta, bubble(2.0, 2.0, 0.0), 4.0, vec![(4.0, with(gray, 200))]),
935            (BubbleColor::Split, bubble(3.0, 1.0, 9.0), 0.0, vec![(3.0, with(green, 200)), (1.0, with(red, 200))]),
936            (BubbleColor::Split, bubble(1.0, 3.0, 0.0), 0.0, vec![(3.0, with(red, 200)), (1.0, with(green, 200))]),
937            (BubbleColor::Split, bubble(0.0, 0.0, 2.0), 0.0, vec![(2.0, with(gray, 200))]),
938            (BubbleColor::Volume, bubble(4.0, 0.0, 0.0), 0.0, vec![(4.0, with(gray, 200))]),
939        ];
940        for (mode, bubble, max_delta, expected) in cases {
941            let bubbles =
942                VolumeBubbles::new(config(mode), BarClock::new(Timeframe::Min1), None, []);
943            let (discs, n) = bubbles.discs(&bubble, max_delta);
944            assert_eq!(discs[..n], expected[..], "{mode:?} {bubble:?}");
945        }
946    }
947
948    /// Guards automatic screen density in both directions, conservation of volume and side
949    /// through grouping and filtering, and the original sizes and borders where space permits
950    /// or the grouping is explicit. Expected centres, totals and linear diameters are by hand.
951    #[test]
952    fn automatic_bubbles_leave_space_and_preserve_the_summarized_executions() {
953        let (buy, sell) = (Some(OrderSide::Buy), Some(OrderSide::Sell));
954        let tape = vec![
955            trade(1, 100.0, 2.0, buy),
956            trade(61, 99.0, 3.0, sell),
957            trade(121, 100.0, 1.0, None),
958            trade(181, 80.0, 12.0, buy),
959            trade(241, 60.0, 9.0, sell),
960        ];
961        let config = VolumeBubblesConfig {
962            scaling_mode: BubbleScalingMode::Linear,
963            clamp_std_devs: f64::INFINITY,
964            ..VolumeBubblesConfig::default()
965        };
966        let (green, red) = (
967            with_opacity(config.buy_color, config.opacity),
968            with_opacity(config.sell_color, config.opacity),
969        );
970        let [r, g, b, a] = config
971            .border_color
972            .to_array()
973            .map(|channel| channel.div_ceil(2));
974        let soft_border = Some(Color32::from_rgba_premultiplied(r, g, b, a));
975        let border = Some(config.border_color);
976        #[rustfmt::skip]
977        let cases = [
978            // A 12-point screen cell sums 2 buy + 3 sell + 1 unknown = 6 lots.
979            // Diameters span 2.25..9 points for 6..12 lots, with half-opacity borders.
980            ("session", Automatic, 3.0, None, tape.clone(),
981                vec![(102.0, 96.0, 2.25, red, soft_border), (114.0, 120.0, 9.0, green, soft_border),
982                     (114.0, 144.0, 5.625, red, soft_border)]),
983            ("filter after screen grouping", Automatic, 3.0, Some(5.0), tape.clone(),
984                vec![(102.0, 96.0, 2.25, red, soft_border), (114.0, 120.0, 9.0, green, soft_border),
985                     (114.0, 144.0, 5.625, red, soft_border)]),
986            ("vertical stack", Automatic, 80.0, None,
987                vec![trade(1, 100.0, 2.0, buy), trade(2, 99.0, 3.0, sell), trade(61, 80.0, 12.0, buy), trade(121, 60.0, 8.5, sell)],
988                vec![(100.0, 96.0, 2.25, red, soft_border), (180.0, 120.0, 9.0, green, soft_border),
989                     (260.0, 144.0, 5.625, red, soft_border)]),
990            ("spacious detail", Automatic, 80.0, None,
991                vec![trade(1, 100.0, 2.0, buy), trade(61, 20.0, 12.0, sell)],
992                vec![(100.0, 100.0, 4.0, green, border), (180.0, 180.0, 40.0, red, border)]),
993            ("explicit grouping", PriceStep(1.0), 3.0, None, tape,
994                vec![(100.0, 100.0, 4.0 + 36.0 / 11.0, green, border),
995                     (103.0, 101.0, 4.0 + 72.0 / 11.0, red, border),
996                     (106.0, 100.0, 4.0, with_opacity(config.neutral_color, config.opacity), border),
997                     (109.0, 120.0, 40.0, green, border),
998                     (112.0, 140.0, 4.0 + 288.0 / 11.0, red, border)]),
999        ];
1000        for (name, grouping, spacing, min_size_filter, trades, expected) in cases {
1001            let clock = BarClock::new(Timeframe::Min1);
1002            let bars: Vec<_> = (0..5)
1003                .map(|i| {
1004                    Bar::new(
1005                        trade(i * 60, 0.0, 0.0, None).ts,
1006                        100.0,
1007                        101.0,
1008                        20.0,
1009                        80.0,
1010                        1.0,
1011                    )
1012                })
1013                .collect();
1014            let bubbles = VolumeBubbles::new(
1015                VolumeBubblesConfig {
1016                    grouping,
1017                    min_size_filter,
1018                    ..config.clone()
1019                },
1020                clock,
1021                Some(1.0),
1022                &trades,
1023            );
1024            let mut output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
1025                bubbles.render(
1026                    ui.painter(),
1027                    &bars,
1028                    spacing,
1029                    |i| 100.0 + spacing * i as f32,
1030                    |p| 200.0 - p as f32,
1031                );
1032            });
1033            output.textures_delta.clear();
1034            let mesh = output
1035                .shapes
1036                .iter()
1037                .find_map(|shape| match &shape.shape {
1038                    Shape::Mesh(mesh) => Some(mesh),
1039                    _ => None,
1040                })
1041                .unwrap();
1042            let mut wanted = Mesh::default();
1043            for (x, y, diameter, color, border) in expected {
1044                add_disc(
1045                    &mut wanted,
1046                    Pos2::new(x, y),
1047                    diameter / 2.0,
1048                    color,
1049                    border,
1050                    1.0,
1051                );
1052            }
1053            assert_eq!(mesh.vertices.len(), wanted.vertices.len(), "{name}");
1054            assert_eq!(mesh.indices, wanted.indices, "{name}");
1055            for (got, expected) in mesh.vertices.iter().zip(&wanted.vertices) {
1056                assert!(
1057                    (got.pos - expected.pos).length() < 1e-4,
1058                    "{name}: {got:?} vs {expected:?}"
1059                );
1060                assert_eq!(got.color, expected.color, "{name}");
1061            }
1062        }
1063    }
1064
1065    /// Guards the exact position of ungrouped bubbles: a fill sits in its bar's slot at the
1066    /// share of the bar interval that had elapsed, and two fills of one instant and price draw
1067    /// two circles on one spot instead of one, while a price step still merges the levels of a
1068    /// bar that fall on one pixel row. Stamps inside a minute or daily slot leave every
1069    /// grouping at the same position, including trades before the first visible stamp.
1070    #[test]
1071    fn bubbles_follow_their_bar_slots_and_execution_times() {
1072        let ten = "2024-03-15T10:00:00Z".parse::<DateTime<Utc>>().unwrap();
1073        let mesh = |grouping, fills: &[(i64, f64)], stamp, timeframe: Timeframe| {
1074            let clock = BarClock::new(timeframe);
1075            let open = clock.bar_open(ten);
1076            let bars = [0, timeframe.duration_ms()].map(|ms| {
1077                Bar::new(
1078                    open + TimeDelta::milliseconds(ms) + TimeDelta::seconds(stamp),
1079                    100.0,
1080                    101.0,
1081                    99.0,
1082                    100.0,
1083                    1.0,
1084                )
1085            });
1086            let config = VolumeBubblesConfig {
1087                grouping,
1088                ..VolumeBubblesConfig::default()
1089            };
1090            let trades = Vec::from_iter(
1091                fills
1092                    .iter()
1093                    .map(|&(s, p)| Tick::new(open + TimeDelta::seconds(s), p, 1.0, None)),
1094            );
1095            let bubbles = VolumeBubbles::new(config, clock, Some(1.0), &trades);
1096            let ctx = egui::Context::default();
1097            let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
1098                bubbles.render(
1099                    ui.painter(),
1100                    &bars,
1101                    50.0,
1102                    |i| 100.0 + 50.0 * i as f32,
1103                    |price| 200.0 - price as f32,
1104                );
1105            });
1106            output.textures_delta.clear();
1107            output
1108                .shapes
1109                .iter()
1110                .find_map(|shape| match &shape.shape {
1111                    Shape::Mesh(mesh) => Some(Arc::clone(mesh)),
1112                    _ => None,
1113                })
1114                .unwrap_or_default()
1115        };
1116        let centres = |fills: &[(i64, f64)]| {
1117            let mesh = mesh(Ungrouped, fills, 0, Timeframe::Min1);
1118            let sides = mesh.vertices.len() / fills.len();
1119            Vec::from_iter(
1120                mesh.vertices
1121                    .chunks(sides)
1122                    .map(|disc| (disc[0].pos.x, disc[0].pos.y)),
1123            )
1124        };
1125        // A quarter into the first bar, half way, and three quarters into the second.
1126        assert_eq!(
1127            centres(&[(15, 100.0), (30, 99.0), (75, 99.0), (30, 99.0)]),
1128            [
1129                (87.5, 100.0),
1130                (100.0, 101.0),
1131                (100.0, 101.0),
1132                (137.5, 101.0)
1133            ]
1134        );
1135        // 99.0 and 99.25 both round to row 101: one disc, as large as a lone fill's.
1136        assert_eq!(
1137            mesh(
1138                PriceStep(0.25),
1139                &[(15, 99.0), (20, 99.25)],
1140                0,
1141                Timeframe::Min1
1142            )
1143            .vertices
1144            .len(),
1145            mesh(Ungrouped, &[(15, 99.0)], 0, Timeframe::Min1)
1146                .vertices
1147                .len()
1148        );
1149        for (timeframe, stamp, second) in [
1150            (Timeframe::Min1, 30, 60),
1151            (Timeframe::Day1, 4 * 3600, 86400),
1152        ] {
1153            for grouping in [Automatic, PriceStep(1.0)] {
1154                let fills = [(40, 100.0), (second + 40, 101.0)];
1155                let opens = mesh(grouping, &fills, 0, timeframe);
1156                let inside = mesh(grouping, &fills, stamp, timeframe);
1157                assert!(!opens.vertices.is_empty());
1158                assert_eq!(
1159                    inside.vertices, opens.vertices,
1160                    "{grouping:?} {timeframe:?}"
1161                );
1162            }
1163        }
1164        // 150 + (10/60 - 1/2) * 50 = 133 1/3; the first slot also retains
1165        // executions before its stamp: 100 + (15/60 - 1/2) * 50 = 87.5.
1166        // Ten-second windows sit at seconds 15 and 75, the latter at x = 137.5.
1167        for (grouping, second_x) in [
1168            (Ungrouped, 133.33333),
1169            (Executions(1), 133.33333),
1170            (TimeWindow(TimeDelta::seconds(10)), 137.5),
1171        ] {
1172            let fills = [(15, 100.0), (70, 101.0)];
1173            let inside = mesh(grouping, &fills, 30, Timeframe::Min1);
1174            let opens = mesh(grouping, &fills, 0, Timeframe::Min1);
1175            assert_eq!(inside.vertices, opens.vertices, "{grouping:?}");
1176            let second = inside.vertices.len() / 2;
1177            assert!((inside.vertices[0].pos.x - 87.5).abs() < 0.01);
1178            assert!((inside.vertices[second].pos.x - second_x).abs() < 0.01);
1179        }
1180    }
1181}