1use crate::chart::coords::ChartMapping;
4use crate::data::{Footprint, FootprintBar, FootprintLevel, NodeSensitivity};
5use crate::model::Bar;
6use crate::theme::ThemeExt;
7use egui::epaint::{Galley, Mesh, Shape};
8use egui::{Align2, Color32, FontId, Painter, Rect, Stroke, StrokeKind, pos2, vec2};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13const FONT_LIMITS: [f32; 2] = [8.0, 24.0];
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum FootprintMode {
19 BidAsk,
21 Volume,
23 Delta,
25 DeltaVolume,
27 DominantSide,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub enum FootprintColor {
35 Volume,
37 Delta,
39 Imbalance,
41 Fixed,
43 DeltaOpacity,
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize)]
52#[serde(default)]
53pub struct FootprintConfig {
54 pub modes: Vec<FootprintMode>,
56 pub profile: bool,
58 pub color_by: FootprintColor,
60 pub ask_color: Color32,
62 pub bid_color: Color32,
64 pub text_color: Color32,
66 pub neutral_color: Color32,
68 pub auto_row_height: Option<f32>,
71 pub min_text_height: f32,
74 pub imbalance_ratio: f64,
76 pub imbalance_min_volume: f64,
80 pub diagonal: bool,
82 pub show_poc: bool,
84 pub value_area: Option<f64>,
86 pub value_area_high: Option<Stroke>,
88 pub value_area_low: Option<Stroke>,
90 pub node_sensitivity: NodeSensitivity,
92 pub abbreviate: bool,
95 pub show_zero: bool,
98 pub font_size: [f32; 2],
103 pub extreme_ratio: bool,
106 pub pullback: bool,
108 pub statistics: bool,
111 #[cfg(feature = "studies")]
114 pub studies: Option<crate::studies::FootprintStudyConfig>,
115}
116
117impl Default for FootprintConfig {
118 fn default() -> Self {
119 Self {
120 modes: vec![FootprintMode::BidAsk],
121 profile: true,
122 color_by: FootprintColor::Delta,
123 ask_color: crate::tokens::DESIGN_TOKENS.semantic.chart.bullish,
124 bid_color: crate::tokens::DESIGN_TOKENS.semantic.chart.bearish,
125 text_color: Color32::WHITE,
126 neutral_color: Color32::GRAY,
127 auto_row_height: Some(16.0),
128 min_text_height: 12.0,
129 imbalance_ratio: 3.0,
130 imbalance_min_volume: 0.0,
131 diagonal: true,
132 show_poc: true,
133 value_area: Some(0.7),
134 value_area_high: Some(Stroke::new(1.0, Color32::GRAY)),
135 value_area_low: Some(Stroke::new(1.0, Color32::GRAY)),
136 node_sensitivity: NodeSensitivity::default(),
137 abbreviate: false,
138 show_zero: true,
139 font_size: [10.0, 10.0],
140 extreme_ratio: false,
141 pullback: false,
142 statistics: false,
143 #[cfg(feature = "studies")]
144 studies: None,
145 }
146 }
147}
148
149pub fn pullback_range(bar: &Bar, flow: &Footprint) -> Option<(i64, i64)> {
156 let source = flow.bar_at(bar.time)?;
157 let low = *source.levels.first_key_value()?.0;
158 let high = *source.levels.last_key_value()?.0;
159 let close = flow.level_of(bar.close)?.clamp(low, high);
160 let range = if bar.close >= bar.open {
161 (close, high)
162 } else {
163 (low, close)
164 };
165 (range.0 < range.1).then_some(range)
166}
167
168pub(crate) fn footprint_column_width(bar_spacing: f32, config: &FootprintConfig) -> Option<f32> {
172 let columns = config.modes.len().min(4);
173 let width = bar_spacing * 0.92 / columns as f32;
174 (columns > 0 && bar_spacing.is_finite() && width - 0.5 >= 1.0).then_some(width)
175}
176
177pub fn render_footprint(
190 painter: &Painter,
191 mapping: &ChartMapping,
192 bars: &[Bar],
193 flow: &Footprint,
194 config: &FootprintConfig,
195) {
196 let Some(width) = footprint_column_width(mapping.bar_spacing, config) else {
197 return;
198 };
199 if bars.is_empty() {
200 return;
201 }
202 let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
203 let first = mapping
204 .x_to_idx(mapping.rect.left())
205 .saturating_sub(1)
206 .min(bars.len());
207 let end = mapping
208 .x_to_idx(mapping.rect.right())
209 .saturating_add(2)
210 .min(bars.len());
211 let background = painter.add(Shape::Noop);
213 let mut mesh = Mesh::default();
214 let columns = config.modes.len().min(4);
215 let text_background = painter.ctx().active_theme().chart.bg.to_opaque();
216 let gutter = (mapping.bar_spacing * 0.04).min(2.5);
218 let complete_from = flow.complete_from();
220 for (index, bar) in bars.iter().enumerate().take(end).skip(first) {
221 let Some(source) = flow
222 .bar_at(bar.time)
223 .filter(|_| complete_from.is_some_and(|from| bar.time >= from))
224 else {
225 continue;
226 };
227 let x = mapping.idx_to_x(index);
228 let left = x - mapping.bar_spacing * 0.46;
229 let price_left = (left - gutter).max(mapping.rect.left());
230 let price_right = left.max(price_left);
231 let spine = (price_left + price_right) * 0.5;
232 let color = if bar.close > bar.open {
233 config.ask_color
234 } else if bar.close < bar.open {
235 config.bid_color
236 } else {
237 config.text_color
238 };
239 let stroke = Stroke::new((gutter * 0.2).clamp(0.25, 1.0), color);
240 let [high, low, open, close] =
241 [bar.high, bar.low, bar.open, bar.close].map(|price| mapping.price_to_y(price));
242 if price_right > price_left && [high, low, open, close].iter().all(|y| y.is_finite()) {
243 painter.line_segment([pos2(spine, high), pos2(spine, low)], stroke);
244 painter.line_segment(
245 [pos2(spine, open), pos2(spine, close)],
246 Stroke::new(stroke.width * 2.0, color),
247 );
248 painter.line_segment([pos2(price_left, open), pos2(spine, open)], stroke);
249 painter.line_segment([pos2(spine, close), pos2(price_right, close)], stroke);
250 }
251 let group = row_group(mapping, bar, flow, config);
253 let grouped;
254 let shown = if group > 1 {
255 grouped = group_bar(source, group);
256 &grouped
257 } else {
258 source
259 };
260 let max_volume = shown
261 .levels
262 .values()
263 .map(|row| row.volume())
264 .fold(0.0, f64::max);
265 let max_delta = shown
266 .levels
267 .values()
268 .map(|row| row.delta().abs())
269 .fold(0.0, f64::max);
270 let area = config.value_area.and_then(|share| shown.value_area(share));
271 let poc = config.show_poc.then(|| shown.poc()).flatten();
272 let pullback = config
273 .pullback
274 .then(|| pullback_range(bar, flow))
275 .flatten()
276 .map(|(low, high)| (low.div_euclid(group), high.div_euclid(group)));
277 let text_rows: Vec<_> = shown
278 .levels
279 .values()
280 .filter(|row| {
281 let price =
282 row.level as f64 * flow.price_step() * group as f64 - flow.tick_size() * 0.5;
283 let y1 = mapping.price_to_y(price);
284 let y2 = mapping.price_to_y(price + flow.price_step() * group as f64);
285 let center = (y1 + y2) * 0.5;
286 y1.is_finite()
287 && y2.is_finite()
288 && center - FONT_LIMITS[0] * 0.5 >= mapping.rect.top()
289 && center + FONT_LIMITS[0] * 0.5 <= mapping.rect.bottom()
290 && (y2 - y1).abs() >= config.min_text_height.max(10.0)
291 && Rect::from_min_max(
292 pos2(left, y1.min(y2)),
293 pos2(x + mapping.bar_spacing * 0.46, y1.max(y2)),
294 )
295 .intersects(mapping.rect)
296 })
297 .collect();
298 let text: Vec<_> = config
299 .modes
300 .iter()
301 .take(columns)
302 .map(|mode| {
303 column_galleys(&painter, &text_rows, max_volume, *mode, config, width - 2.5)
304 })
305 .collect();
306 for row in shown.levels.values() {
307 let price =
308 row.level as f64 * flow.price_step() * group as f64 - flow.tick_size() * 0.5;
309 let y1 = mapping.price_to_y(price);
310 let y2 = mapping.price_to_y(price + flow.price_step() * group as f64);
311 if !y1.is_finite() || !y2.is_finite() {
312 continue;
313 }
314 for (column, mode) in config.modes.iter().take(columns).enumerate() {
315 let left = x - mapping.bar_spacing * 0.46 + column as f32 * width;
316 let rect = Rect::from_min_max(
317 pos2(left, y1.min(y2)),
318 pos2(left + width - 0.5, y1.max(y2)),
319 );
320 if !rect.intersects(mapping.rect) {
321 continue;
322 }
323 let color = cell_color(shown, row, max_volume, max_delta, config);
324 let share = if config.profile && max_volume > 0.0 {
325 (row.volume() / max_volume) as f32
326 } else {
327 1.0
328 };
329 let fill = Rect::from_min_size(
330 rect.min,
331 vec2(rect.width() * share, rect.height().max(1.0)),
332 );
333 let mut fills = [
334 area.is_some_and(|(low, high)| (low..=high).contains(&row.level))
335 .then_some((rect, config.neutral_color.gamma_multiply(0.12))),
336 Some((fill, color.gamma_multiply(0.5))),
337 None,
338 ];
339 if config.color_by == FootprintColor::Imbalance
340 && shown.imbalance(
341 row.level,
342 config.imbalance_ratio,
343 config.diagonal,
344 config.imbalance_min_volume,
345 ) == (true, true)
346 {
347 let (bid, ask) = fill.split_left_right_at_x(fill.center().x);
348 fills[1] = Some((bid, config.bid_color.gamma_multiply(0.5)));
349 fills[2] = Some((ask, config.ask_color.gamma_multiply(0.5)));
350 }
351 for &(fill, color) in fills.iter().flatten() {
352 mesh.add_colored_rect(fill.intersect(mapping.rect), color);
353 }
354 if pullback.is_some_and(|(low, high)| (low..=high).contains(&row.level)) {
355 painter.rect_stroke(
356 rect,
357 0,
358 Stroke::new(1.0, config.neutral_color),
359 StrokeKind::Inside,
360 );
361 }
362 if poc == Some(row.level) && rect.width() >= 12.0 && rect.height() >= 4.0 {
363 painter.rect_stroke(
364 rect,
365 0,
366 Stroke::new(1.0, config.text_color),
367 StrokeKind::Inside,
368 );
369 }
370 if rect.height() >= config.min_text_height.max(10.0)
371 && let Some(galley) = text[column]
372 .as_ref()
373 .and_then(|galleys| galleys.get(&row.level))
374 {
375 let mut galley = galley.clone();
376 if !galley.is_empty() && galley.size().y > rect.height() {
377 let size = galley.job.sections[0].format.font_id.size;
378 for size in [
379 (size * rect.height() / galley.size().y).max(FONT_LIMITS[0]),
380 FONT_LIMITS[0],
381 ] {
382 galley = painter.layout_no_wrap(
383 galley.text().to_owned(),
384 FontId::monospace(size),
385 config.text_color,
386 );
387 if galley.size().y <= rect.height() {
388 break;
389 }
390 }
391 }
392 if !galley.is_empty() && galley.size().y <= rect.height() {
393 let center = rect.center().x;
394 let x = match mode {
395 FootprintMode::DominantSide if row.bid > row.ask => rect.left() + 1.0,
396 FootprintMode::DominantSide if row.ask > row.bid => {
397 rect.right() - 1.0 - galley.size().x
398 }
399 _ => center - galley.size().x / 2.0,
400 };
401 let at = pos2(x, rect.center().y - galley.size().y / 2.0);
402 if mapping
404 .rect
405 .contains_rect(Rect::from_min_size(at, galley.size()))
406 {
407 let backing = Rect::from_min_size(at, galley.size())
410 .expand2(vec2(2.0, 1.0))
411 .intersect(rect)
412 .intersect(mapping.rect);
413 mesh.add_colored_rect(backing, text_background);
414 for &(fill, color) in fills.iter().flatten() {
415 if fill.intersects(backing) {
416 mesh.add_colored_rect(fill.intersect(backing), color);
417 }
418 }
419 painter.galley(at, galley, config.text_color);
420 }
421 }
422 }
423 }
424 }
425 if config.extreme_ratio {
426 let step = flow.price_step() * group as f64;
427 let first = shown.levels.first_key_value();
428 let last = shown.levels.last_key_value();
429 for (extreme, high) in [
431 (last, true),
432 (first.filter(|_| shown.levels.len() > 1), false),
433 ] {
434 let Some((&level, row)) = extreme else {
435 continue;
436 };
437 let edge = mapping
438 .price_to_y((level + i64::from(high)) as f64 * step - flow.tick_size() * 0.5);
439 let text = row.ratio().map_or_else(
440 || "A/B n/a".to_owned(),
441 |ratio| {
442 format!(
443 "A/B {}",
444 egui::emath::format_with_decimals_in_range(ratio, 0..=2)
445 )
446 },
447 );
448 let galley =
449 painter.layout_no_wrap(text, FontId::monospace(10.0), config.text_color);
450 let anchor = if high {
451 Align2::CENTER_BOTTOM
452 } else {
453 Align2::CENTER_TOP
454 };
455 let bounds = anchor.anchor_size(pos2(x, edge), galley.size());
456 if edge.is_finite()
457 && galley.size().x <= mapping.bar_spacing
458 && mapping.rect.contains_rect(bounds)
459 {
460 painter.galley(bounds.min, galley, config.text_color);
461 }
462 }
463 }
464 }
465 painter.set(background, Shape::mesh(mesh));
466}
467
468fn font_limits(sizes: [f32; 2]) -> [f32; 2] {
471 let [a, b] = sizes.map(|size| {
472 if size.is_nan() {
473 10.0
474 } else {
475 size.clamp(FONT_LIMITS[0], FONT_LIMITS[1])
476 }
477 });
478 [a.min(b), a.max(b)]
479}
480
481fn column_galleys(
483 painter: &Painter,
484 rows: &[&FootprintLevel],
485 max_volume: f64,
486 mode: FootprintMode,
487 config: &FootprintConfig,
488 width: f32,
489) -> Option<BTreeMap<i64, Arc<Galley>>> {
490 if rows.is_empty() || width < 18.0 {
491 return None;
492 }
493 let [smallest, largest] = font_limits(config.font_size);
494 for compact in [false, true] {
495 let mut fits = true;
496 let galleys = rows
497 .iter()
498 .map(|row| {
499 let mut text = cell_text(mode, row, config.abbreviate || compact, config.show_zero);
500 if compact {
501 text = text.replace(" × ", "×").replace(" / ", "/");
502 }
503 let share = if max_volume > 0.0 {
504 (row.volume() / max_volume) as f32
505 } else {
506 0.0
507 };
508 let size = smallest + (largest - smallest) * share;
509 let mut galley = painter.layout_no_wrap(
510 text.clone(),
511 FontId::monospace(size),
512 config.text_color,
513 );
514 if galley.size().x > width {
515 let size = (size * width / galley.size().x).max(FONT_LIMITS[0]);
516 galley =
517 painter.layout_no_wrap(text, FontId::monospace(size), config.text_color);
518 }
519 if galley.size().x > width && size > FONT_LIMITS[0] {
520 galley = painter.layout_no_wrap(
521 galley.text().to_owned(),
522 FontId::monospace(FONT_LIMITS[0]),
523 config.text_color,
524 );
525 }
526 fits &= galley.size().x <= width;
527 (row.level, galley)
528 })
529 .collect();
530 if fits {
531 return Some(galleys);
532 }
533 }
534 None
535}
536
537fn cell_text(
539 mode: FootprintMode,
540 row: &FootprintLevel,
541 abbreviate: bool,
542 show_zero: bool,
543) -> String {
544 let hidden = |value: f64| value == 0.0 && !show_zero;
545 let number = |value: f64| {
546 if hidden(value) {
547 String::new()
548 } else {
549 volume_text(value, abbreviate)
550 }
551 };
552 let signed = |value: f64| match number(value) {
553 text if text.is_empty() || value < 0.0 => text,
554 text => format!("+{text}"),
555 };
556 match mode {
557 FootprintMode::BidAsk if hidden(row.bid) && hidden(row.ask) => String::new(),
558 FootprintMode::BidAsk => format!("{} × {}", number(row.bid), number(row.ask)),
559 FootprintMode::Volume => number(row.volume()),
560 FootprintMode::Delta => signed(row.delta()),
561 FootprintMode::DeltaVolume if hidden(row.delta()) && hidden(row.volume()) => String::new(),
562 FootprintMode::DeltaVolume => {
563 format!("{} / {}", signed(row.delta()), number(row.volume()))
564 }
565 FootprintMode::DominantSide => number(row.bid.max(row.ask)),
566 }
567}
568
569fn volume_text(value: f64, abbreviate: bool) -> String {
570 if abbreviate && value.abs() >= 1000.0 {
571 let (scaled, unit) = if value.abs() >= 999_950.0 {
573 (value / 1e6, 'M')
574 } else {
575 (value / 1e3, 'K')
576 };
577 let text = format!("{scaled:.1}");
578 return format!("{}{unit}", text.strip_suffix(".0").unwrap_or(&text));
579 }
580 if value != 0.0 && value.abs() < 0.001 {
581 format!("{value:.2e}")
582 } else {
583 egui::emath::format_with_decimals_in_range(
584 value,
585 0..=if value.abs() < 100.0 { 3 } else { 0 },
586 )
587 }
588}
589
590fn row_group(mapping: &ChartMapping, bar: &Bar, flow: &Footprint, config: &FootprintConfig) -> i64 {
591 let raw_height =
592 (mapping.price_to_y(bar.close + flow.price_step()) - mapping.price_to_y(bar.close)).abs();
593 config
594 .auto_row_height
595 .filter(|h| h.is_finite() && *h > 0.0)
596 .map_or(1, |height| {
597 (height / raw_height.max(0.01))
598 .ceil()
599 .clamp(1.0, 1_000_000.0) as i64
600 })
601}
602
603fn group_bar(source: &FootprintBar, group: i64) -> FootprintBar {
604 let mut shown = FootprintBar {
605 time: source.time,
606 levels: BTreeMap::new(),
607 bid: source.bid,
608 ask: source.ask,
609 unknown: source.unknown,
610 min_delta: source.min_delta,
611 max_delta: source.max_delta,
612 };
613 for row in source.levels.values() {
614 let key = row.level.div_euclid(group);
615 let target = shown.levels.entry(key).or_insert_with(|| FootprintLevel {
616 level: key,
617 ..Default::default()
618 });
619 target.bid += row.bid;
620 target.ask += row.ask;
621 target.unknown += row.unknown;
622 }
623 shown
624}
625
626fn cell_color(
627 bar: &FootprintBar,
628 row: &FootprintLevel,
629 max_volume: f64,
630 max_delta: f64,
631 config: &FootprintConfig,
632) -> Color32 {
633 match config.color_by {
634 FootprintColor::Fixed => config.neutral_color,
635 FootprintColor::DeltaOpacity if row.delta() == 0.0 => config.neutral_color,
636 FootprintColor::DeltaOpacity => {
637 let side = if row.delta() > 0.0 {
638 config.ask_color
639 } else {
640 config.bid_color
641 };
642 side.gamma_multiply((row.delta().abs() / max_delta) as f32)
643 }
644 FootprintColor::Volume => config.neutral_color.gamma_multiply(if max_volume > 0.0 {
645 (row.volume() / max_volume) as f32
646 } else {
647 0.0
648 }),
649 FootprintColor::Delta if row.bid + row.ask == 0.0 => config.neutral_color,
650 FootprintColor::Delta => {
651 let share = if row.bid + row.ask > 0.0 {
652 row.ask / (row.bid + row.ask)
653 } else {
654 0.5
655 };
656 config
657 .bid_color
658 .lerp_to_gamma(config.ask_color, share as f32)
659 }
660 FootprintColor::Imbalance => {
661 match bar.imbalance(
662 row.level,
663 config.imbalance_ratio,
664 config.diagonal,
665 config.imbalance_min_volume,
666 ) {
667 (true, false) => config.bid_color,
668 (false, true) => config.ask_color,
669 _ => config.neutral_color,
670 }
671 }
672 }
673}
674
675pub fn render_footprint_statistics(
687 painter: &Painter,
688 mapping: &ChartMapping,
689 bars: &[Bar],
690 flow: &Footprint,
691 text_color: Color32,
692) {
693 let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
694 let labels = [
695 "Vol", "Bid", "Ask", "Delta", "Min", "Max", "CVD", "Delta %", "Ask/Bid",
696 ];
697 let row_height = (mapping.rect.height() / labels.len() as f32).min(12.0);
698 let font = FontId::monospace(10.0);
699 let labels: Vec<_> = labels
700 .into_iter()
701 .map(|label| painter.layout_no_wrap(label.into(), font.clone(), text_color))
702 .collect();
703 let rows_fit = labels.iter().all(|label| label.size().y <= row_height);
704 let label_width = labels
705 .iter()
706 .map(|label| label.size().x)
707 .fold(0.0, f32::max)
708 + 8.0;
709 let mut cumulative = 0.0;
710 let complete = flow.complete_from();
711 let mut has_data = false;
712 let mut columns = Vec::new();
713 for (i, bar) in bars.iter().enumerate() {
714 let Some(row) = flow
715 .bar_at(bar.time)
716 .filter(|_| complete.is_some_and(|from| bar.time >= from))
717 else {
718 continue;
719 };
720 cumulative += row.delta();
721 let x = mapping.idx_to_x(i);
722 if !mapping.is_x_visible(x) {
723 continue;
724 }
725 has_data = true;
726 if x - mapping.bar_spacing * 0.5 < mapping.rect.left() + label_width
727 || x + mapping.bar_spacing * 0.5 > mapping.rect.right()
728 {
729 continue;
730 }
731 let values = [
732 Some(row.volume()),
733 Some(row.bid),
734 Some(row.ask),
735 Some(row.delta()),
736 Some(row.min_delta),
737 Some(row.max_delta),
738 Some(cumulative),
739 row.delta_percent(),
740 (row.bid > 0.0).then(|| row.ask / row.bid),
741 ];
742 columns.push((x, values));
743 }
744 let mut size = 10.0;
745 let mut fitted = Vec::new();
746 if mapping.bar_spacing >= 22.0 && rows_fit {
747 'fitting: for abbreviate in [false, true] {
748 for font_size in [10.0, FONT_LIMITS[0]] {
749 size = font_size;
750 fitted = columns
751 .iter()
752 .map(|(x, values)| {
753 (
754 *x,
755 values
756 .iter()
757 .copied()
758 .enumerate()
759 .map(|(line, value)| {
760 let text = value.map_or_else(
761 || "n/a".to_owned(),
762 |value| match line {
763 7 => format!("{value:.1}%"),
764 8 => format!("{value:.2}:1"),
765 _ => volume_text(value, abbreviate),
766 },
767 );
768 painter.layout_no_wrap(
769 text,
770 FontId::monospace(size),
771 text_color,
772 )
773 })
774 .collect::<Vec<_>>(),
775 )
776 })
777 .collect::<Vec<_>>();
778 if fitted.iter().all(|(_, values)| {
779 values
780 .iter()
781 .all(|value| value.size().x <= mapping.bar_spacing - 4.0)
782 }) {
783 break 'fitting;
784 }
785 }
786 }
787 }
788 if fitted.is_empty()
789 || fitted.iter().any(|(_, values)| {
790 values
791 .iter()
792 .any(|value| value.size().x > mapping.bar_spacing - 4.0)
793 })
794 {
795 painter.text(
796 mapping.rect.center(),
797 Align2::CENTER_CENTER,
798 if !has_data {
799 "Execution statistics unavailable"
800 } else if !rows_fit {
801 "Enlarge chart for per-bar statistics"
802 } else {
803 "Zoom in for per-bar statistics"
804 },
805 font,
806 text_color,
807 );
808 return;
809 }
810 for (line, label) in labels.into_iter().enumerate() {
811 painter.galley(
812 pos2(
813 mapping.rect.left() + 2.0,
814 mapping.rect.top() + row_height * line as f32,
815 ),
816 label,
817 text_color,
818 );
819 }
820 for (x, values) in fitted {
821 for (line, value) in values.into_iter().enumerate() {
822 let at = pos2(
823 x - value.size().x * 0.5,
824 mapping.rect.top() + row_height * line as f32 + (10.0 - size) * 0.5,
825 );
826 painter.galley(at, value, text_color);
827 }
828 }
829}
830
831#[cfg(feature = "studies")]
832#[derive(Clone, Default)]
833struct FootprintStudyCache {
834 source: Vec<(FootprintBar, i64)>,
835 config: Option<crate::studies::FootprintStudyConfig>,
836 studies: std::sync::Arc<[crate::studies::FootprintStudy]>,
837}
838
839#[cfg(feature = "studies")]
841pub(crate) fn render_footprint_annotations(
842 painter: &Painter,
843 mapping: &ChartMapping,
844 bars: &[Bar],
845 flow: &Footprint,
846 config: &FootprintConfig,
847 chart_id: egui::Id,
848) {
849 let Some(settings) = &config.studies else {
850 return;
851 };
852 let settings = crate::studies::FootprintStudyConfig {
853 imbalance_min_volume: config.imbalance_min_volume,
854 ..settings.clone()
855 };
856 let complete = flow.complete_from();
857 let source: Vec<_> = bars
858 .iter()
859 .filter(|bar| complete.is_some_and(|from| bar.time >= from))
860 .filter_map(|bar| {
861 flow.bar_at(bar.time)
862 .map(|row| (row, row_group(mapping, bar, flow, config)))
863 })
864 .collect();
865 let studies = painter.ctx().data_mut(|data| {
866 let cache = data
867 .get_temp_mut_or_default::<FootprintStudyCache>(chart_id.with("footprint_annotations"));
868 if cache.config.as_ref() != Some(&settings)
869 || cache.source.len() != source.len()
870 || !cache
871 .source
872 .iter()
873 .zip(&source)
874 .all(|((old, group), (new, next_group))| old == *new && group == next_group)
875 {
876 cache.source = source
877 .iter()
878 .map(|(bar, group)| ((*bar).clone(), *group))
879 .collect();
880 let grouped: Vec<_> = source
881 .iter()
882 .map(|(bar, group)| (group_bar(bar, *group), *group))
883 .collect();
884 let rows: Vec<_> = grouped.iter().map(|(bar, group)| (bar, *group)).collect();
885 cache.studies = crate::studies::footprint_studies_on_rows(&rows, &settings).into();
886 cache.config = Some(settings);
887 }
888 cache.studies.clone()
889 });
890 render_footprint_studies(painter, mapping, bars, flow, &studies, config);
891}
892
893#[cfg(feature = "studies")]
902#[cfg_attr(docsrs, doc(cfg(feature = "studies")))]
903pub fn render_footprint_studies(
904 painter: &Painter,
905 mapping: &ChartMapping,
906 bars: &[Bar],
907 flow: &Footprint,
908 studies: &[crate::studies::FootprintStudy],
909 config: &FootprintConfig,
910) {
911 use crate::studies::{FootprintStudyKind, FootprintStudyState};
912 let painter = painter.with_clip_rect(mapping.rect.intersect(painter.clip_rect()));
913 let indices: BTreeMap<_, _> = bars
914 .iter()
915 .enumerate()
916 .filter_map(|(i, bar)| flow.bar_at(bar.time).map(|flow_bar| (flow_bar.time, i)))
917 .collect();
918 let occupied: BTreeMap<_, _> = bars
919 .iter()
920 .enumerate()
921 .filter_map(|(index, bar)| {
922 let source = flow.bar_at(bar.time)?;
923 let group = row_group(mapping, bar, flow, config);
924 let low = source.levels.first_key_value()?.0.div_euclid(group);
925 let high = source.levels.last_key_value()?.0.div_euclid(group);
926 let step = flow.price_step() * group as f64;
927 let y1 = mapping.price_to_y((low as f64 * step - flow.tick_size() * 0.5).min(bar.low));
928 let y2 = mapping
929 .price_to_y(((high as f64 + 1.0) * step - flow.tick_size() * 0.5).max(bar.high));
930 let x = mapping.idx_to_x(index);
931 let rect = Rect::from_min_max(
932 pos2(x - mapping.bar_spacing * 0.46, y1.min(y2)),
933 pos2(x + mapping.bar_spacing * 0.46, y1.max(y2)),
934 );
935 rect.intersects(mapping.rect)
937 .then_some((index, rect.expand2(vec2(0.0, 18.0))))
938 })
939 .collect();
940 let mut labels: Vec<Rect> = Vec::new();
941 let mut hovered = Vec::new();
942 let pointer = painter
943 .ctx()
944 .pointer_hover_pos()
945 .filter(|p| mapping.rect.contains(*p) && painter.clip_rect().contains(*p));
946 let detail = mapping.bar_spacing >= 48.0;
947 for study in studies {
948 let Some(&start) = indices.get(&study.start) else {
949 continue;
950 };
951 let end = study
952 .end
953 .and_then(|time| indices.get(&time).copied())
954 .unwrap_or(bars.len().saturating_sub(1));
955 let left = mapping.idx_to_x(start) - mapping.bar_spacing * 0.46;
956 let right = mapping.idx_to_x(end) + mapping.bar_spacing * 0.46;
957 let y1 = mapping.price_to_y(study.low as f64 * flow.price_step() - flow.tick_size() * 0.5);
958 let y2 = mapping
959 .price_to_y((study.high as f64 + 1.0) * flow.price_step() - flow.tick_size() * 0.5);
960 if !y1.is_finite() || !y2.is_finite() {
961 continue;
962 }
963 let rect = Rect::from_min_max(pos2(left, y1.min(y2)), pos2(right, y1.max(y2)));
964 if !rect.intersects(mapping.rect) {
965 continue;
966 }
967 let color = match study.kind {
968 FootprintStudyKind::StackedBid
969 | FootprintStudyKind::DeltaBid
970 | FootprintStudyKind::RatioBid => config.bid_color,
971 FootprintStudyKind::StackedAsk
972 | FootprintStudyKind::DeltaAsk
973 | FootprintStudyKind::RatioAsk => config.ask_color,
974 _ => config.text_color,
975 };
976 let color = match study.state {
977 FootprintStudyState::Revisited | FootprintStudyState::Expired => {
978 color.gamma_multiply(0.45)
979 }
980 _ => color,
981 };
982 let label = match study.kind {
983 FootprintStudyKind::StackedBid => "Bid stack",
984 FootprintStudyKind::StackedAsk => "Ask stack",
985 FootprintStudyKind::UnfinishedHigh => "UH",
986 FootprintStudyKind::UnfinishedLow => "UL",
987 FootprintStudyKind::NakedPoc => "POC",
988 FootprintStudyKind::DeltaPoc => "Delta POC",
989 FootprintStudyKind::VolumeCluster => "Cluster",
990 FootprintStudyKind::SinglePrint => "Single",
991 FootprintStudyKind::DeltaAsk => "Buy delta %",
992 FootprintStudyKind::DeltaBid => "Sell delta %",
993 FootprintStudyKind::RatioAsk => "Buy ask/bid ratio",
994 FootprintStudyKind::RatioBid => "Sell bid/ask ratio",
995 };
996 let marker = match study.kind {
997 FootprintStudyKind::DeltaAsk | FootprintStudyKind::DeltaBid => Some(0.0),
998 FootprintStudyKind::RatioAsk | FootprintStudyKind::RatioBid => Some(1.0),
999 _ => None,
1000 };
1001 if let Some(order) = marker {
1002 let above = matches!(
1003 study.kind,
1004 FootprintStudyKind::DeltaAsk | FootprintStudyKind::RatioAsk
1005 );
1006 let (edge, away) = if above {
1007 (rect.top(), -1.0)
1008 } else {
1009 (rect.bottom(), 1.0)
1010 };
1011 let radius = (mapping.bar_spacing * 0.2).min(4.0);
1012 let tip = edge + away * (radius * 0.75 + radius * 2.25 * order);
1013 let base = tip + away * radius * 1.5;
1014 let x = (rect.left() + rect.right()) / 2.0;
1015 painter.add(Shape::convex_polygon(
1016 vec![pos2(x, tip), pos2(x - radius, base), pos2(x + radius, base)],
1017 if detail {
1018 color
1019 } else {
1020 color.gamma_multiply(0.5)
1021 },
1022 Stroke::NONE,
1023 ));
1024 let bounds = Rect::from_min_max(
1025 pos2(x - radius, tip.min(base)),
1026 pos2(x + radius, tip.max(base)),
1027 );
1028 if pointer.is_some_and(|p| bounds.expand(2.0).contains(p)) {
1029 let source = flow.bar_at(bars[start].time);
1030 let value = match study.kind {
1031 FootprintStudyKind::DeltaAsk | FootprintStudyKind::DeltaBid => source
1032 .and_then(FootprintBar::delta_percent)
1033 .map(|value| format!("{value:.1}%")),
1034 _ => source.and_then(|bar| {
1035 let (side, opposite) = if above {
1036 (bar.ask, bar.bid)
1037 } else {
1038 (bar.bid, bar.ask)
1039 };
1040 (opposite > 0.0).then(|| format!("{:.2}:1", side / opposite))
1041 }),
1042 };
1043 hovered.push(format!("{label}: {}", value.as_deref().unwrap_or("n/a")));
1044 }
1045 continue;
1046 }
1047 if pointer.is_some_and(|p| rect.expand(2.0).contains(p)) {
1048 let state = match study.state {
1049 FootprintStudyState::Unvisited => "unvisited",
1050 FootprintStudyState::Revisited => "revisited",
1051 FootprintStudyState::Expired => "expired",
1052 FootprintStudyState::Local => "this bar",
1053 };
1054 hovered.push(format!(
1055 "{label}: {} to {} ({state})",
1056 study.low as f64 * flow.price_step(),
1057 study.high as f64 * flow.price_step(),
1058 ));
1059 }
1060 if !detail {
1061 painter.line_segment(
1062 [rect.left_center(), rect.right_center()],
1063 Stroke::new(0.5, color.gamma_multiply(0.3)),
1064 );
1065 continue;
1066 }
1067 if matches!(
1068 study.kind,
1069 FootprintStudyKind::StackedBid
1070 | FootprintStudyKind::StackedAsk
1071 | FootprintStudyKind::VolumeCluster
1072 ) {
1073 painter.rect_filled(rect, 0, color.gamma_multiply(0.12));
1074 }
1075 {
1076 let (at, anchor) = if matches!(
1077 study.kind,
1078 FootprintStudyKind::StackedAsk
1079 | FootprintStudyKind::StackedBid
1080 | FootprintStudyKind::NakedPoc
1081 | FootprintStudyKind::UnfinishedHigh
1082 | FootprintStudyKind::UnfinishedLow
1083 ) {
1084 (rect.right_top() - vec2(0.0, 3.0), Align2::RIGHT_BOTTOM)
1085 } else {
1086 (rect.left_center() - vec2(3.0, 0.0), Align2::RIGHT_CENTER)
1087 };
1088 let galley = painter.layout_no_wrap(label.into(), FontId::monospace(10.0), color);
1089 let bounds = anchor.anchor_size(at, galley.size());
1090 let bar = occupied.get(&start).copied().unwrap_or(rect);
1091 let alternatives = [
1092 bounds,
1093 Align2::CENTER_BOTTOM
1094 .anchor_size(pos2(bar.center().x, bar.top() - 3.0), galley.size()),
1095 Align2::CENTER_TOP
1096 .anchor_size(pos2(bar.center().x, bar.bottom() + 3.0), galley.size()),
1097 ];
1098 if let Some(bounds) = alternatives.into_iter().find(|bounds| {
1099 mapping.rect.contains_rect(*bounds)
1100 && !labels
1101 .iter()
1102 .any(|label| label.expand(1.0).intersects(*bounds))
1103 && !occupied
1104 .values()
1105 .any(|cell| cell.expand(1.0).intersects(*bounds))
1106 }) {
1107 painter.galley(bounds.min, galley, color);
1108 labels.push(bounds);
1109 } else {
1110 let x = mapping.idx_to_x(start) + mapping.bar_spacing * 0.46 + 0.5;
1111 painter.circle_filled(pos2(x, rect.center().y), 0.75, color);
1112 }
1113 }
1114 painter.rect_stroke(rect, 0, Stroke::new(1.0, color), StrokeKind::Inside);
1115 }
1116 if let Some(pointer) = pointer.filter(|_| !hovered.is_empty()) {
1117 hovered.sort();
1118 hovered.dedup();
1119 egui::Tooltip::always_open(
1120 painter.ctx().clone(),
1121 painter.layer_id(),
1122 egui::Id::unique((
1123 "footprint_study_details",
1124 mapping.rect.min.x.to_bits(),
1125 mapping.rect.min.y.to_bits(),
1126 )),
1127 pointer,
1128 )
1129 .show(|ui| {
1130 for text in &hovered {
1131 ui.label(text);
1132 }
1133 });
1134 }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::model::{BarClock, OrderSide, Tick, Timeframe};
1141 use crate::scales::{PriceMap, PriceRange, PriceTransform};
1142
1143 #[test]
1146 fn rendered_rows_surround_their_instrument_ticks() {
1147 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1148 #[rustfmt::skip]
1150 let cases = [
1151 (1.63, 1.0, 1, None, 100.0, 102.0, None),
1152 (1.64, 1.0, 1, None, 100.0, 102.0, Some((80.0, 133.33333))),
1153 (100.0, 1.0, 1, None, 100.0, 102.0, Some((80.0, 133.33333))),
1154 (100.0, 0.25, 1, None, 100.25, 102.0, Some((86.66667, 100.0))),
1155 (100.0, 1.0, 4, None, 100.0, 106.0, Some((57.142857, 148.57143))),
1156 (100.0, 1.0, 1, Some(75.0), 100.0, 106.0, Some((57.142857, 148.57143))),
1157 ];
1158 for (spacing, tick, ticks, auto, price, high, extent) in cases {
1159 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), tick, ticks).unwrap();
1160 flow.push(&Tick::new(time, price, 3.0, Some(OrderSide::Buy)))
1161 .unwrap();
1162 let bars = [Bar::new(time, price, price, price, price, 3.0)];
1163 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1164 let map = ChartMapping::new(
1165 rect,
1166 spacing,
1167 0,
1168 0,
1169 0.0,
1170 PriceMap::new(
1171 PriceTransform::default(),
1172 PriceRange::new(99.0, high),
1173 0.0,
1174 160.0,
1175 false,
1176 ),
1177 );
1178 let config = FootprintConfig {
1179 auto_row_height: auto,
1180 show_poc: false,
1181 value_area: None,
1182 ..Default::default()
1183 };
1184 let mut output = egui::Context::default().run_ui(
1185 egui::RawInput {
1186 screen_rect: Some(rect),
1187 ..Default::default()
1188 },
1189 |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1190 );
1191 output.textures_delta.clear();
1192 let Some((top, bottom)) = extent else {
1193 assert!(
1194 output.shapes.is_empty(),
1195 "spacing {spacing}: unreadable cells"
1196 );
1197 continue;
1198 };
1199 let extent = output
1200 .shapes
1201 .iter()
1202 .filter_map(|s| match &s.shape {
1203 Shape::Mesh(mesh) => Some(mesh),
1204 _ => None,
1205 })
1206 .flat_map(|mesh| mesh.vertices.iter())
1207 .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| {
1208 (lo.min(v.pos.y), hi.max(v.pos.y))
1209 });
1210 assert!(
1211 (extent.0 - top).abs() < 0.01 && (extent.1 - bottom).abs() < 0.01,
1212 "spacing {spacing}, tick {tick}, ticks {ticks}, auto {auto:?}: {extent:?}, expected {top}..{bottom}"
1213 );
1214 }
1215 }
1216
1217 #[test]
1219 fn bar_cut_by_dropped_executions_is_not_drawn() {
1220 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1221 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1222 let trades = [100.0, 100.25].map(|price| Tick::new(time, price, 3.0, Some(OrderSide::Buy)));
1223 trades.iter().for_each(|trade| _ = flow.push(trade));
1224 flow.drop_through(time, &trades[1..]);
1225 assert!(flow.bar_at(time).is_some());
1226 let bars = [Bar::new(time, 100.0, 100.25, 100.0, 100.25, 3.0)];
1227 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1228 let map = ChartMapping::new(
1229 rect,
1230 240.0,
1231 0,
1232 0,
1233 0.0,
1234 PriceMap::new(
1235 PriceTransform::default(),
1236 PriceRange::new(99.0, 102.0),
1237 0.0,
1238 160.0,
1239 false,
1240 ),
1241 );
1242 let config = FootprintConfig::default();
1243 let mut output = egui::Context::default().run_ui(
1244 egui::RawInput {
1245 screen_rect: Some(rect),
1246 ..Default::default()
1247 },
1248 |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1249 );
1250 output.textures_delta.clear();
1251 assert!(output.shapes.iter().all(|shape| match &shape.shape {
1252 Shape::Text(_) => false,
1253 Shape::Mesh(mesh) => mesh.is_empty(),
1254 _ => true,
1255 }));
1256 }
1257
1258 #[test]
1259 fn four_modes_hide_small_text_and_group_adjacent_rows() {
1260 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1261 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1262 for (price, size, side) in [
1263 (100.0, 2.0, OrderSide::Sell),
1264 (100.0, 3.0, OrderSide::Buy),
1265 (100.25, 4.0, OrderSide::Buy),
1266 ] {
1267 flow.push(&Tick::new(time, price, size, Some(side)))
1268 .unwrap();
1269 }
1270 let bars = [Bar::new(time, 100.0, 100.25, 100.0, 100.25, 9.0)];
1271 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1272 let mapping = |high| {
1273 ChartMapping::new(
1274 rect,
1275 240.0,
1276 0,
1277 0,
1278 0.0,
1279 PriceMap::new(
1280 PriceTransform::default(),
1281 PriceRange::new(99.0, high),
1282 0.0,
1283 160.0,
1284 false,
1285 ),
1286 )
1287 };
1288 let map = mapping(102.0);
1289 for (minimum, auto, high, left, expected) in [
1293 (14.0, None, 102.0, 0.0, 0),
1294 (10.0, None, 102.0, 0.0, 8),
1295 (10.0, Some(30.0), 102.0, 0.0, 4),
1296 (10.0, None, 102.8, 0.0, 8),
1297 (10.0, None, 102.0, 30.0, 6),
1298 ] {
1299 let map = mapping(high);
1300 let plot = ChartMapping::new(
1301 Rect::from_min_max(pos2(left, 0.0), rect.max),
1302 240.0,
1303 0,
1304 0,
1305 0.0,
1306 map.price,
1307 );
1308 let ctx = egui::Context::default();
1309 let config = FootprintConfig {
1310 modes: vec![
1311 FootprintMode::BidAsk,
1312 FootprintMode::Volume,
1313 FootprintMode::Delta,
1314 FootprintMode::DeltaVolume,
1315 ],
1316 min_text_height: minimum,
1317 auto_row_height: auto,
1318 ..Default::default()
1319 };
1320 let mut output = ctx.run_ui(
1321 egui::RawInput {
1322 screen_rect: Some(rect),
1323 ..Default::default()
1324 },
1325 |ui| render_footprint(ui.painter(), &plot, &bars, &flow, &config),
1326 );
1327 output.textures_delta.clear();
1328 let texts: Vec<_> = output
1329 .shapes
1330 .iter()
1331 .filter_map(|shape| match &shape.shape {
1332 Shape::Text(text) => Some(text.galley.text()),
1333 _ => None,
1334 })
1335 .collect();
1336 assert_eq!(
1337 texts.len(),
1338 expected,
1339 "minimum {minimum}, auto {auto:?}, top {high}, left {left}: {texts:?}"
1340 );
1341 if auto.is_some() {
1342 assert_eq!(texts, ["2 × 7", "9", "+5", "+5 / 9"]);
1343 }
1344 }
1345 let mut dual = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1347 for (price, size, side) in [
1348 (100.0, 1.0, OrderSide::Sell),
1349 (100.25, 9.0, OrderSide::Sell),
1350 (100.25, 12.0, OrderSide::Buy),
1351 (100.5, 2.0, OrderSide::Buy),
1352 ] {
1353 dual.push(&Tick::new(time, price, size, Some(side)))
1354 .unwrap();
1355 }
1356 let config = FootprintConfig {
1357 color_by: FootprintColor::Imbalance,
1358 auto_row_height: None,
1359 value_area: None,
1360 show_poc: false,
1361 ..Default::default()
1362 };
1363 let ctx = egui::Context::default();
1364 for (minimum, bid, ask) in [(0.0, true, true), (10.0, false, true), (13.0, false, false)] {
1365 let config = FootprintConfig {
1366 imbalance_min_volume: minimum,
1367 ..config.clone()
1368 };
1369 let mut output = ctx.run_ui(
1370 egui::RawInput {
1371 screen_rect: Some(rect),
1372 ..Default::default()
1373 },
1374 |ui| render_footprint(ui.painter(), &map, &bars, &dual, &config),
1375 );
1376 output.textures_delta.clear();
1377 for (color, expected) in [(config.bid_color, bid), (config.ask_color, ask)] {
1378 assert_eq!(
1379 output.shapes.iter().any(|shape| match &shape.shape {
1380 Shape::Mesh(mesh) => mesh
1381 .vertices
1382 .iter()
1383 .any(|v| v.color == color.gamma_multiply(0.5)),
1384 _ => false,
1385 }),
1386 expected,
1387 "each diagonal side must reach minimum {minimum}"
1388 );
1389 }
1390 }
1391 let mut small = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1393 small
1394 .push(&Tick::new(time, 100.0, 0.125, Some(OrderSide::Sell)))
1395 .unwrap();
1396 small
1397 .push(&Tick::new(time, 100.0, 0.375, Some(OrderSide::Buy)))
1398 .unwrap();
1399 for (mode, expected) in [
1400 (FootprintMode::BidAsk, "0.125 × 0.375"),
1401 (FootprintMode::Volume, "0.5"),
1402 (FootprintMode::Delta, "+0.25"),
1403 (FootprintMode::DeltaVolume, "+0.25 / 0.5"),
1404 ] {
1405 let config = FootprintConfig {
1406 modes: vec![mode],
1407 min_text_height: 10.0,
1408 auto_row_height: None,
1409 ..Default::default()
1410 };
1411 let mut output = ctx.run_ui(
1412 egui::RawInput {
1413 screen_rect: Some(rect),
1414 ..Default::default()
1415 },
1416 |ui| render_footprint(ui.painter(), &map, &bars, &small, &config),
1417 );
1418 output.textures_delta.clear();
1419 assert!(
1420 output.shapes.iter().any(|shape| match &shape.shape {
1421 Shape::Text(text) => text.galley.text() == expected,
1422 _ => false,
1423 }),
1424 "missing {expected}"
1425 );
1426 }
1427 let config = FootprintConfig {
1430 modes: vec![FootprintMode::Volume],
1431 extreme_ratio: true,
1432 auto_row_height: None,
1433 ..Default::default()
1434 };
1435 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1436 for (price, size, side) in [
1437 (100.0, 2.0, OrderSide::Sell),
1438 (100.0, 3.0, OrderSide::Buy),
1439 (100.25, 4.0, OrderSide::Buy),
1440 ] {
1441 flow.push(&Tick::new(time, price, size, Some(side)))
1442 .unwrap();
1443 }
1444 let mut output = ctx.run_ui(
1445 egui::RawInput {
1446 screen_rect: Some(rect),
1447 ..Default::default()
1448 },
1449 |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1450 );
1451 output.textures_delta.clear();
1452 for expected in ["A/B n/a", "A/B 1.5"] {
1453 assert!(
1454 output.shapes.iter().any(|shape| match &shape.shape {
1455 Shape::Text(text) => text.galley.text() == expected,
1456 _ => false,
1457 }),
1458 "missing {expected}"
1459 );
1460 }
1461 }
1462
1463 #[cfg(feature = "studies")]
1465 #[test]
1466 fn imbalance_minimum_applies_after_grouping_to_cells_and_zones() {
1467 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1468 let bars = [Bar::new(time, 100.0, 107.0, 100.0, 107.0, 39.0)];
1469 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1470 let map = ChartMapping::new(
1471 rect,
1472 120.0,
1473 0,
1474 0,
1475 0.0,
1476 PriceMap::new(
1477 PriceTransform::default(),
1478 PriceRange::new(98.0, 110.0),
1479 0.0,
1480 160.0,
1481 false,
1482 ),
1483 );
1484 for ticks in [1, 2] {
1485 let ctx = egui::Context::default();
1486 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, ticks).unwrap();
1487 for (price, bid, ask) in [
1490 (100.0, 1.0, 0.0),
1491 (102.0, 1.0, 12.0),
1492 (104.0, 1.0, 9.0),
1493 (106.0, 0.0, 15.0),
1494 ] {
1495 for offset in [0.0, 1.0] {
1496 for (size, side) in [(bid / 2.0, OrderSide::Sell), (ask / 2.0, OrderSide::Buy)]
1497 {
1498 flow.push(&Tick::new(time, price + offset, size, Some(side)))
1499 .unwrap();
1500 }
1501 }
1502 }
1503 for (minimum, middle, zone) in [
1504 (0.0, true, true),
1505 (9.0, true, true),
1506 (10.0, false, false),
1507 (0.0, true, true),
1508 ] {
1509 let config = FootprintConfig {
1510 color_by: FootprintColor::Imbalance,
1511 profile: false,
1512 value_area: None,
1513 show_poc: false,
1514 auto_row_height: (ticks == 1).then_some(20.0),
1515 imbalance_min_volume: minimum,
1516 studies: Some(crate::studies::FootprintStudyConfig {
1517 naked_poc: false,
1518 delta_poc: false,
1519 unfinished_auctions: false,
1520 ..Default::default()
1521 }),
1522 ..Default::default()
1523 };
1524 let mut output = ctx.run_ui(
1525 egui::RawInput {
1526 screen_rect: Some(rect),
1527 ..Default::default()
1528 },
1529 |ui| {
1530 render_footprint(ui.painter(), &map, &bars, &flow, &config);
1531 render_footprint_annotations(
1532 ui.painter(),
1533 &map,
1534 &bars,
1535 &flow,
1536 &config,
1537 egui::Id::unique("minimum"),
1538 );
1539 },
1540 );
1541 output.textures_delta.clear();
1542 for (price, active) in [(102.0, true), (104.0, middle), (106.0, true)] {
1543 let top = map.price_to_y(price + 1.5);
1544 let color = if active {
1545 config.ask_color
1546 } else {
1547 config.neutral_color
1548 }
1549 .gamma_multiply(0.5);
1550 let filled = output.shapes.iter().any(|s| match &s.shape {
1551 Shape::Mesh(mesh) => mesh
1552 .vertices
1553 .iter()
1554 .any(|v| (v.pos.y - top).abs() < 0.01 && v.color == color),
1555 _ => false,
1556 });
1557 assert!(filled, "ticks {ticks}, minimum {minimum}, price {price}");
1558 }
1559 let has_zone = output.shapes.iter().any(
1560 |s| matches!(&s.shape, Shape::Rect(r) if r.stroke.color == config.ask_color),
1561 );
1562 assert_eq!(has_zone, zone, "ticks {ticks}, minimum {minimum}");
1563 }
1564 }
1565 }
1566
1567 #[test]
1571 fn statistics_keep_shared_rows_and_aligned_values_or_explain_the_overview() {
1572 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1573 let next = time + chrono::TimeDelta::minutes(1);
1574 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1575 for (time, bid, ask) in [(time, 3.0, 13.0), (next, 2500.0, 0.0)] {
1576 for (size, side) in [(bid, OrderSide::Sell), (ask, OrderSide::Buy)] {
1577 if size > 0.0 {
1578 flow.push(&Tick::new(time, 100.0, size, Some(side)))
1579 .unwrap();
1580 }
1581 }
1582 }
1583 let bars = [
1584 Bar::new(time, 100.0, 100.0, 100.0, 100.0, 16.0),
1585 Bar::new(next, 100.0, 100.0, 100.0, 100.0, 2500.0),
1586 ];
1587 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 112.0));
1588 let empty = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1589 for (spacing, available, large, height) in [
1590 (60.0, true, false, 112.0),
1591 (40.0, true, false, 112.0),
1592 (3.0, true, false, 112.0),
1593 (60.0, false, false, 112.0),
1594 (40.0, true, true, 112.0),
1595 (60.0, true, false, 90.0),
1596 ] {
1597 let rect = Rect::from_min_size(rect.min, vec2(rect.width(), height));
1598 let mut shown = flow.clone();
1599 let mut bars = bars.clone();
1600 if large {
1601 shown
1602 .push(&Tick::new(
1603 next,
1604 100.0,
1605 123_454_289.0,
1606 Some(OrderSide::Sell),
1607 ))
1608 .unwrap();
1609 bars[1].volume = 123_456_789.0;
1610 }
1611 let map = ChartMapping::new(
1612 rect,
1613 spacing,
1614 0,
1615 1,
1616 0.0,
1617 PriceMap::new(
1618 PriceTransform::default(),
1619 PriceRange::new(99.0, 102.0),
1620 0.0,
1621 112.0,
1622 false,
1623 ),
1624 );
1625 let mut output = egui::Context::default().run_ui(
1626 egui::RawInput {
1627 screen_rect: Some(rect),
1628 ..Default::default()
1629 },
1630 |ui| {
1631 render_footprint_statistics(
1632 ui.painter(),
1633 &map,
1634 &bars,
1635 if available { &shown } else { &empty },
1636 Color32::WHITE,
1637 )
1638 },
1639 );
1640 output.textures_delta.clear();
1641 let texts: Vec<_> = output
1642 .shapes
1643 .iter()
1644 .filter_map(|s| match &s.shape {
1645 Shape::Text(t) => Some(t),
1646 _ => None,
1647 })
1648 .collect();
1649 for text in &texts {
1650 assert!(
1651 rect.contains_rect(Rect::from_min_size(text.pos, text.galley.size())),
1652 "statistics text is clipped at height {height}: {} {:?}",
1653 text.galley.text(),
1654 Rect::from_min_size(text.pos, text.galley.size()),
1655 );
1656 }
1657 if spacing == 3.0 || !available || height == 90.0 {
1658 assert_eq!(texts.len(), 1);
1659 assert_eq!(
1660 texts[0].galley.text(),
1661 if !available {
1662 "Execution statistics unavailable"
1663 } else if height == 90.0 {
1664 "Enlarge chart for per-bar statistics"
1665 } else {
1666 "Zoom in for per-bar statistics"
1667 }
1668 );
1669 continue;
1670 }
1671 assert_eq!(
1672 texts.len(),
1673 27,
1674 "nine labels and two complete columns at {spacing}"
1675 );
1676 assert_eq!(
1677 texts
1678 .iter()
1679 .filter(|t| t.galley.text() == "Delta %")
1680 .count(),
1681 1
1682 );
1683 for (column, expected) in [
1684 ["16", "3", "13", "10", "-3", "10", "10", "62.5%", "4.33:1"],
1685 if large {
1686 [
1687 "123.5M", "123.5M", "0", "-123.5M", "-123.5M", "0", "-123.5M", "-100.0%",
1688 "0.00:1",
1689 ]
1690 } else {
1691 [
1692 "2500", "2500", "0", "-2500", "-2500", "0", "-2490", "-100.0%", "0.00:1",
1693 ]
1694 },
1695 ]
1696 .into_iter()
1697 .enumerate()
1698 {
1699 for (line, expected) in expected.into_iter().enumerate() {
1700 let text = texts[9 + column * 9 + line];
1701 assert_eq!(text.galley.text(), expected);
1702 assert!(
1703 (text.pos.x + text.galley.size().x * 0.5 - map.idx_to_x(column)).abs()
1704 < 0.01
1705 );
1706 }
1707 }
1708 }
1709 }
1710
1711 #[test]
1714 fn price_spines_identify_open_close_and_direction_without_covering_values() {
1715 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1716 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1717 for price in [100.0, 100.25, 100.5, 100.75, 101.0] {
1718 flow.push(&Tick::new(time, price, 3.0, Some(OrderSide::Buy)))
1719 .unwrap();
1720 }
1721 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1722 for spacing in [60.0, 3.0] {
1723 for inverted in [false, true] {
1724 for (open, close) in [(100.25, 100.75), (100.75, 100.25), (100.5, 100.5)] {
1725 let map = ChartMapping::new(
1726 rect,
1727 spacing,
1728 0,
1729 0,
1730 0.0,
1731 PriceMap::new(
1732 PriceTransform::default(),
1733 PriceRange::new(99.0, 103.0),
1734 0.0,
1735 160.0,
1736 inverted,
1737 ),
1738 );
1739 let bars = [Bar::new(time, open, 101.0, 100.0, close, 15.0)];
1740 let config = FootprintConfig::default();
1741 let mut output = egui::Context::default().run_ui(
1742 egui::RawInput {
1743 screen_rect: Some(rect),
1744 ..Default::default()
1745 },
1746 |ui| render_footprint(ui.painter(), &map, &bars, &flow, &config),
1747 );
1748 output.textures_delta.clear();
1749 let lines: Vec<_> = output
1750 .shapes
1751 .iter()
1752 .filter_map(|s| match &s.shape {
1753 Shape::LineSegment { points, stroke } => Some((points, stroke)),
1754 _ => None,
1755 })
1756 .collect();
1757 assert_eq!(
1758 lines.len(),
1759 4,
1760 "price spine and body with opening and closing ticks"
1761 );
1762 let y = |price: f64| {
1764 let y = (103.0 - price) as f32 * 40.0;
1765 if inverted { 160.0 - y } else { y }
1766 };
1767 assert_eq!(lines[0].0.map(|p| p.y), [y(101.0), y(100.0)]);
1768 assert_eq!(lines[2].0.map(|p| p.y), [y(open); 2]);
1769 assert_eq!(lines[3].0.map(|p| p.y), [y(close); 2]);
1770 assert!(lines[2].0[0].x < lines[2].0[1].x);
1771 assert!(lines[3].0[0].x < lines[3].0[1].x);
1772 let color = if close > open {
1773 config.ask_color
1774 } else if close < open {
1775 config.bid_color
1776 } else {
1777 config.text_color
1778 };
1779 assert!(lines.iter().all(|(_, stroke)| stroke.color == color));
1780 for shape in &output.shapes {
1781 if let Shape::Text(text) = &shape.shape {
1782 assert!(text.pos.x > lines[3].0[1].x);
1783 }
1784 }
1785 }
1786 }
1787 }
1788 }
1789
1790 #[test]
1793 fn detail_columns_keep_large_values_and_overview_preserves_fills() {
1794 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1795 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1796 for (price, bid, ask) in [
1797 (100.0, 1234.0, 3456.0),
1798 (100.25, 1.0, 2.0),
1799 (100.5, 3456.0, 1234.0),
1800 ] {
1801 for (size, side) in [(bid, OrderSide::Sell), (ask, OrderSide::Buy)] {
1802 flow.push(&Tick::new(time, price, size, Some(side)))
1803 .unwrap();
1804 }
1805 }
1806 let bars = [Bar::new(time, 100.0, 100.5, 100.0, 100.5, 9383.0)];
1807 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1808 for mode in [FootprintMode::BidAsk, FootprintMode::DeltaVolume] {
1809 for (spacing, numbers, outlines, clipped) in [
1810 (60.0, 3, 1, false),
1811 (57.0, 3, 1, false),
1812 (22.0, 0, 1, false),
1813 (3.0, 0, 0, false),
1814 (60.0, 3, 1, true),
1815 ] {
1816 let mut visible = flow.clone();
1817 let mut bars = bars.clone();
1818 if clipped {
1819 visible
1821 .push(&Tick::new(time, 102.0, 1e20, Some(OrderSide::Buy)))
1822 .unwrap();
1823 bars[0].high = 102.0;
1824 bars[0].close = 102.0;
1825 bars[0].volume += 1e20;
1826 }
1827 let map = ChartMapping::new(
1828 rect,
1829 spacing,
1830 0,
1831 0,
1832 0.0,
1833 PriceMap::new(
1834 PriceTransform::default(),
1835 PriceRange::new(99.0, 102.0),
1836 0.0,
1837 160.0,
1838 false,
1839 ),
1840 );
1841 let config = FootprintConfig {
1842 modes: vec![mode],
1843 auto_row_height: None,
1844 ..Default::default()
1845 };
1846 let mut output = egui::Context::default().run_ui(
1847 egui::RawInput {
1848 screen_rect: Some(rect),
1849 ..Default::default()
1850 },
1851 |ui| render_footprint(ui.painter(), &map, &bars, &visible, &config),
1852 );
1853 output.textures_delta.clear();
1854 let texts: Vec<_> = output
1855 .shapes
1856 .iter()
1857 .filter_map(|s| match &s.shape {
1858 Shape::Text(t) => Some(t),
1859 _ => None,
1860 })
1861 .collect();
1862 assert_eq!(texts.len(), numbers, "{mode:?}, spacing {spacing}");
1863 for text in texts {
1864 assert!(
1865 text.pos.x >= map.idx_to_x(0) - spacing * 0.46
1866 && text.pos.x + text.galley.size().x
1867 <= map.idx_to_x(0) + spacing * 0.46,
1868 "execution values stay inside their column"
1869 );
1870 assert!(
1871 text.galley
1872 .job
1873 .sections
1874 .iter()
1875 .all(|section| section.format.font_id.size >= 8.0)
1876 );
1877 }
1878 assert_eq!(output.shapes.iter().filter(|s| matches!(&s.shape, Shape::Rect(r) if r.stroke.color == config.text_color)).count(), outlines, "POC at {spacing}");
1879 assert!(
1880 output
1881 .shapes
1882 .iter()
1883 .any(|s| matches!(&s.shape, Shape::Mesh(m) if !m.is_empty()))
1884 );
1885 }
1886 }
1887 }
1888
1889 #[test]
1894 fn number_colour_and_font_choices_follow_their_documented_rules() {
1895 let row = |bid, ask, unknown| FootprintLevel {
1896 level: 0,
1897 bid,
1898 ask,
1899 unknown,
1900 };
1901 let config = |abbreviate, show_zero| FootprintConfig {
1902 abbreviate,
1903 show_zero,
1904 ..Default::default()
1905 };
1906 #[rustfmt::skip]
1907 let texts = [
1908 (FootprintMode::Volume, row(0.0, 1234.0, 0.0), config(true, true), "1.2K"),
1909 (FootprintMode::Volume, row(0.0, 1234.0, 0.0), config(false, true), "1234"),
1910 (FootprintMode::Volume, row(0.0, 999.0, 0.0), config(true, true), "999"),
1911 (FootprintMode::Volume, row(0.0, 1000.0, 0.0), config(true, true), "1K"),
1912 (FootprintMode::Volume, row(0.0, 999_949.0, 0.0), config(true, true), "999.9K"),
1913 (FootprintMode::Volume, row(0.0, 999_950.0, 0.0), config(true, true), "1M"),
1914 (FootprintMode::Volume, row(0.0, 3_400_000.0, 0.0), config(true, true), "3.4M"),
1915 (FootprintMode::Delta, row(2500.0, 0.0, 0.0), config(true, true), "-2.5K"),
1916 (FootprintMode::BidAsk, row(0.0, 7.0, 0.0), config(false, false), " × 7"),
1917 (FootprintMode::BidAsk, row(0.0, 0.0, 4.0), config(false, false), ""),
1918 (FootprintMode::BidAsk, row(0.0, 0.0, 4.0), config(false, true), "0 × 0"),
1919 (FootprintMode::Delta, row(3.0, 3.0, 0.0), config(false, false), ""),
1920 (FootprintMode::DominantSide, row(5.0, 3.0, 0.0), config(false, true), "5"),
1921 (FootprintMode::DominantSide, row(2.0, 3.0, 9.0), config(false, true), "3"),
1922 ];
1923 for (mode, row, config, expected) in texts {
1924 assert_eq!(
1925 cell_text(mode, &row, config.abbreviate, config.show_zero),
1926 expected,
1927 "{mode:?} {row:?}"
1928 );
1929 }
1930 let bar = FootprintBar {
1931 time: "2026-01-05T10:00:00Z".parse().unwrap(),
1932 levels: BTreeMap::new(),
1933 bid: 0.0,
1934 ask: 0.0,
1935 unknown: 0.0,
1936 min_delta: 0.0,
1937 max_delta: 0.0,
1938 };
1939 let colors = |color_by, row: FootprintLevel| {
1940 let config = FootprintConfig {
1941 color_by,
1942 ..Default::default()
1943 };
1944 (cell_color(&bar, &row, 10.0, 6.0, &config), config)
1945 };
1946 let (color, config) = colors(FootprintColor::DeltaOpacity, row(2.0, 8.0, 0.0));
1947 assert_eq!(color, config.ask_color.gamma_multiply(1.0));
1948 let (color, config) = colors(FootprintColor::DeltaOpacity, row(5.0, 2.0, 0.0));
1949 assert_eq!(color, config.bid_color.gamma_multiply(0.5));
1950 let (color, config) = colors(FootprintColor::DeltaOpacity, row(4.0, 4.0, 1.0));
1951 assert_eq!(color, config.neutral_color);
1952 let (color, config) = colors(FootprintColor::Fixed, row(2.0, 8.0, 0.0));
1953 assert_eq!(color, config.neutral_color);
1954 assert_eq!(font_limits([4.0, 100.0]), [8.0, 24.0]);
1955 assert_eq!(font_limits([18.0, 12.0]), [12.0, 18.0]);
1956 assert_eq!(font_limits([f32::NAN, 12.0]), [10.0, 12.0]);
1957 }
1958
1959 #[test]
1964 fn the_pullback_range_runs_from_the_close_to_the_extreme_it_left() {
1965 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1966 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1967 for price in [100.0, 100.25, 100.5] {
1968 flow.push(&Tick::new(time, price, 1.0, Some(OrderSide::Buy)))
1969 .unwrap();
1970 }
1971 for (open, close, expected) in [
1972 (100.0, 100.25, Some((401, 402))),
1973 (100.5, 100.25, Some((400, 401))),
1974 (100.0, 100.5, None),
1975 (100.5, 100.0, None),
1976 (100.0, 99.0, None),
1977 (100.0, 101.0, None),
1978 ] {
1979 let bar = Bar::new(time, open, 100.5, 100.0, close, 3.0);
1980 assert_eq!(pullback_range(&bar, &flow), expected, "{open} to {close}");
1981 }
1982 }
1983
1984 #[cfg(feature = "studies")]
1988 #[test]
1989 fn studies_are_painted_as_their_state_and_kind_say() {
1990 use crate::studies::{FootprintStudy, FootprintStudyKind, FootprintStudyState};
1991 let time = "2026-01-05T10:00:00Z".parse().unwrap();
1992 let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 0.25, 1).unwrap();
1993 flow.push(&Tick::new(time, 100.0, 1.0, Some(OrderSide::Buy)))
1994 .unwrap();
1995 let bars = [Bar::new(time, 100.0, 100.25, 100.0, 100.25, 1.0)];
1996 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 160.0));
1997 let map = ChartMapping::new(
1998 rect,
1999 60.0,
2000 0,
2001 0,
2002 0.0,
2003 PriceMap::new(
2004 PriceTransform::default(),
2005 PriceRange::new(99.0, 102.0),
2006 0.0,
2007 160.0,
2008 false,
2009 ),
2010 );
2011 let study = |kind, state, low| FootprintStudy {
2012 start: time,
2013 end: Some(time),
2014 low,
2015 high: low,
2016 kind,
2017 state,
2018 };
2019 let studies = [
2020 study(
2021 FootprintStudyKind::StackedAsk,
2022 FootprintStudyState::Unvisited,
2023 400,
2024 ),
2025 study(
2026 FootprintStudyKind::StackedAsk,
2027 FootprintStudyState::Revisited,
2028 401,
2029 ),
2030 study(
2031 FootprintStudyKind::StackedAsk,
2032 FootprintStudyState::Expired,
2033 402,
2034 ),
2035 study(
2036 FootprintStudyKind::DeltaAsk,
2037 FootprintStudyState::Local,
2038 400,
2039 ),
2040 ];
2041 let config = FootprintConfig::default();
2042 let ctx = egui::Context::default();
2043 let mut output = ctx.run_ui(
2044 egui::RawInput {
2045 screen_rect: Some(rect),
2046 ..Default::default()
2047 },
2048 |ui| render_footprint_studies(ui.painter(), &map, &bars, &flow, &studies, &config),
2049 );
2050 output.textures_delta.clear();
2051 let strokes: Vec<_> = output
2052 .shapes
2053 .iter()
2054 .filter_map(|shape| match &shape.shape {
2055 Shape::Rect(rect) if rect.stroke.width > 0.0 => Some(rect.stroke.color),
2056 _ => None,
2057 })
2058 .collect();
2059 let dimmed = config.ask_color.gamma_multiply(0.45);
2060 assert_eq!(strokes, [config.ask_color, dimmed, dimmed]);
2061 let top = map.price_to_y(100.125);
2062 let triangle = output.shapes.iter().find_map(|shape| match &shape.shape {
2063 Shape::Path(path) if path.fill == config.ask_color => Some(path.points.clone()),
2064 _ => None,
2065 });
2066 assert!(
2067 triangle.is_some_and(|points| points.len() == 3 && points.iter().all(|p| p.y < top)),
2068 "buy marker above the bar"
2069 );
2070 let next = time + chrono::TimeDelta::minutes(1);
2072 flow.push(&Tick::new(next, 100.0, 2.0, Some(OrderSide::Buy)))
2073 .unwrap();
2074 let bars = [
2075 bars[0].clone(),
2076 Bar::new(next, 100.0, 100.0, 100.0, 100.0, 2.0),
2077 ];
2078 let local = FootprintStudy {
2079 start: next,
2080 end: Some(next),
2081 low: 400,
2082 high: 400,
2083 kind: FootprintStudyKind::SinglePrint,
2084 state: FootprintStudyState::Local,
2085 };
2086 for spacing in [60.0, 3.0] {
2087 let map = ChartMapping {
2088 bar_spacing: spacing,
2089 base_idx: 1,
2090 ..map.clone()
2091 };
2092 let mut output = ctx.run_ui(
2093 egui::RawInput {
2094 screen_rect: Some(rect),
2095 ..Default::default()
2096 },
2097 |ui| {
2098 render_footprint(ui.painter(), &map, &bars, &flow, &config);
2099 render_footprint_studies(
2100 ui.painter(),
2101 &map,
2102 &bars,
2103 &flow,
2104 &[
2105 local.clone(),
2106 FootprintStudy {
2107 kind: FootprintStudyKind::VolumeCluster,
2108 ..local.clone()
2109 },
2110 FootprintStudy {
2111 kind: FootprintStudyKind::DeltaPoc,
2112 ..local.clone()
2113 },
2114 FootprintStudy {
2115 kind: FootprintStudyKind::DeltaAsk,
2116 high: 401,
2117 ..local.clone()
2118 },
2119 FootprintStudy {
2120 kind: FootprintStudyKind::RatioAsk,
2121 high: 401,
2122 ..local.clone()
2123 },
2124 studies[3].clone(),
2125 ],
2126 &config,
2127 );
2128 },
2129 );
2130 output.textures_delta.clear();
2131 let texts: Vec<_> = output
2132 .shapes
2133 .iter()
2134 .filter_map(|s| match &s.shape {
2135 Shape::Text(t) => Some(t),
2136 _ => None,
2137 })
2138 .collect();
2139 for (index, caption) in texts
2140 .iter()
2141 .enumerate()
2142 .filter(|(_, t)| ["Single", "Cluster", "Delta POC"].contains(&t.galley.text()))
2143 {
2144 let bounds = Rect::from_min_size(caption.pos, caption.galley.size());
2145 assert!(
2146 texts.iter().enumerate().all(|(other, t)| other == index
2147 || !bounds.intersects(Rect::from_min_size(t.pos, t.galley.size()))),
2148 "caption over execution values"
2149 );
2150 for shape in &output.shapes {
2151 if let Shape::Path(path) = &shape.shape {
2152 assert!(
2153 !bounds.intersects(Rect::from_points(&path.points)),
2154 "caption over threshold marker"
2155 );
2156 }
2157 }
2158 }
2159 if spacing == 60.0 {
2160 let marker = output
2161 .shapes
2162 .iter()
2163 .find_map(|s| match &s.shape {
2164 Shape::Circle(circle) => Some(circle),
2165 _ => None,
2166 })
2167 .expect("a caption without room leaves a marker");
2168 assert!(
2169 marker.center.x > map.idx_to_x(1) + spacing * 0.46,
2170 "annotation marker uses the gap after the cells, away from the price spine"
2171 );
2172 }
2173 if spacing == 3.0 {
2174 assert!(
2175 output
2176 .shapes
2177 .iter()
2178 .all(|s| !matches!(&s.shape, Shape::Rect(r) if r.stroke.width > 0.0)),
2179 "no historical boxes at overview zoom"
2180 );
2181 for shape in &output.shapes {
2182 if let Shape::Path(path) = &shape.shape {
2183 let (lo, hi) = path
2184 .points
2185 .iter()
2186 .fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
2187 (lo.min(p.x), hi.max(p.x))
2188 });
2189 assert!(
2190 hi - lo <= spacing * 0.8,
2191 "overview triangle stays within its bar"
2192 );
2193 }
2194 }
2195 }
2196 }
2197 let map = ChartMapping { base_idx: 1, ..map };
2198 let at = pos2(map.idx_to_x(1), map.price_to_y(100.0));
2199 let mut details = false;
2200 for _ in 0..3 {
2202 let mut output = ctx.run_ui(
2203 egui::RawInput {
2204 screen_rect: Some(rect),
2205 events: vec![egui::Event::PointerMoved(at)],
2206 ..Default::default()
2207 },
2208 |ui| {
2209 render_footprint_studies(
2210 ui.painter(),
2211 &map,
2212 &bars,
2213 &flow,
2214 std::slice::from_ref(&local),
2215 &config,
2216 )
2217 },
2218 );
2219 output.textures_delta.clear();
2220 details |= output.shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.text().starts_with("Single: 100 to 100")));
2221 }
2222 assert!(details, "compact annotations retain hover details");
2223 }
2224}