1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(default)]
21pub struct VolumeBubblesConfig {
22 pub min_diameter: f32,
24 pub max_diameter: f32,
26 pub opacity: u8,
28 pub buy_color: Color32,
30 pub sell_color: Color32,
32 pub neutral_color: Color32,
34 pub color_by: BubbleColor,
36 pub scaling_mode: BubbleScalingMode,
38 #[serde(deserialize_with = "null_as_infinity")]
42 pub clamp_std_devs: f64,
43 pub show_border: bool,
45 pub border_color: Color32,
47 pub min_size_filter: Option<f64>,
49 pub grouping: BubbleGrouping,
51}
52
53fn 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81pub enum BubbleGrouping {
82 Automatic,
87 Ungrouped,
91 TimeWindow(TimeDelta),
95 PriceStep(f64),
98 Executions(usize),
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106pub enum BubbleScalingMode {
107 Linear,
109 Logarithmic,
111 SquareRoot,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117pub enum BubbleColor {
118 Side,
121 Delta,
124 Split,
129 AbsoluteDelta,
133 DeltaPercent,
137 Volume,
139}
140
141#[derive(Clone, Debug, Default, PartialEq)]
143pub(crate) struct Bubble {
144 pub(crate) at: DateTime<Utc>,
146 pub(crate) price: f64,
148 pub(crate) buy: f64,
150 pub(crate) sell: f64,
152 pub(crate) volume: f64,
154 pub(crate) trades: usize,
155 pub(crate) buys: usize,
157 pub(crate) sells: usize,
159}
160
161impl Bubble {
162 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 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 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
209fn 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
218pub(crate) struct VolumeBubbles {
220 pub(crate) config: VolumeBubblesConfig,
221 clock: BarClock,
222 step: Option<f64>,
223 window: i64,
225 group: usize,
227 bubbles: BTreeMap<(DateTime<Utc>, i64), Bubble>,
230 trades: VecDeque<Tick>,
233 evicted: usize,
236 mesh: Mutex<Arc<Mesh>>,
238}
239
240impl VolumeBubbles {
241 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 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 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 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 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 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 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 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 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 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 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
560fn 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
592fn 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 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
649struct Scale {
651 low: f64,
652 high: f64,
653 mode: BubbleScalingMode,
654 min_diameter: f32,
655 max_diameter: f32,
656}
657
658impl Scale {
659 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 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 #[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 #[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 #[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 #[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 #[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 #[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 ("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 #[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 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 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 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}