Skip to main content

egui_charts/drawings/rendering/trading/
volume_profile.rs

1//! Volume tools: anchored VWAP and the fixed range and anchored volume profiles
2
3use crate::data::{StudyDataRequest, VolumeProfile};
4use crate::drawings::domain::Drawing;
5use crate::drawings::domain::DrawingToolType;
6use crate::drawings::rendering::{ChartView, MAX_CACHED_SPANS, bars_between};
7use crate::model::{Bar, ProfileResolution, Timeframe};
8use crate::painting::span_cache::{SpanCache, SpanFingerprint};
9use crate::painting::volume_profile::ProfileStyle;
10use crate::styles::stroke;
11use chrono::{DateTime, Utc};
12use egui::{Color32, Pos2, Shape, Stroke};
13use std::cell::RefCell;
14use std::ops::Range;
15use std::rc::Rc;
16
17/// Bars between the saved running sums of an anchored VWAP.
18const VWAP_CHECKPOINT: usize = 1024;
19
20/// Colour of the anchored VWAP's bands, its first band's fill included.
21const VWAP_BAND_COLOR: Color32 = Color32::from_rgb(76, 175, 80);
22
23type ProfileCache = SpanCache<Option<VolumeProfile>, (usize, Option<f64>)>;
24
25/// Rows, row size, the finer span and its history's address.
26type FinerCache = SpanCache<Option<VolumeProfile>, (usize, Option<f64>, SpanFingerprint, usize)>;
27
28thread_local! {
29    static VWAP_CHECKPOINTS: RefCell<SpanCache<Vec<VwapSums>>> =
30        const { RefCell::new(SpanCache::new(MAX_CACHED_SPANS)) };
31    /// Profiles by their number of rows.
32    static PROFILES: RefCell<ProfileCache> =
33        const { RefCell::new(SpanCache::new(MAX_CACHED_SPANS)) };
34    /// Profiles built from finer bars.
35    static FINER_PROFILES: RefCell<FinerCache> =
36        const { RefCell::new(SpanCache::new(MAX_CACHED_SPANS)) };
37}
38
39impl Drawing {
40    /// Renders the anchored VWAP from the anchor bar to the last bar at the drawing's width,
41    /// with the enabled bands of its [`VwapConfig`](crate::drawings::VwapConfig), the first one
42    /// filled at 5%.
43    pub(crate) fn render_anchored_vwap(&self, painter: &egui::Painter, view: &ChartView) {
44        let Some(anchor_at) = self.chart_points.first() else {
45            return;
46        };
47        if let Some((first, bars)) = bars_between(view.bars, anchor_at.bar_idx, f32::INFINITY) {
48            self.draw_vwap(painter, view, first, bars);
49        }
50    }
51
52    fn draw_vwap(&self, painter: &egui::Painter, view: &ChartView, first: usize, bars: &[Bar]) {
53        // Only bars inside the clip rect (plus one on each side) become
54        // points; bar_to_x is linear, so they are found without a scan.
55        let x0 = (view.bar_to_x)(first as f32);
56        let step = (view.bar_to_x)(first as f32 + 1.0) - x0;
57        if step.is_nan() || step <= 0.0 {
58            return;
59        }
60        let clip = painter.clip_rect();
61        let from = (((clip.left() - x0) / step).floor() - 1.0).max(0.0) as usize;
62        let to = ((((clip.right() - x0) / step).ceil() + 2.0).max(0.0) as usize).min(bars.len());
63        if from >= to {
64            return;
65        }
66
67        // The checkpoints only cover whole blocks before the last bar, so the
68        // forming bar's updates reuse them.
69        let covered = (bars.len() - 1) / VWAP_CHECKPOINT * VWAP_CHECKPOINT;
70        let checkpoints = VWAP_CHECKPOINTS.with_borrow_mut(|cache| {
71            cache.get((), first, &bars[..covered], view.revision, vwap_checkpoints)
72        });
73        // The VWAP, then the upper and lower line of every enabled band, in standard deviations
74        // from it.
75        let config = self.vwap.unwrap_or_default();
76        let mut offsets = vec![0.0];
77        for band in config.bands.iter().filter(|b| b.enabled) {
78            offsets.extend([band.multiplier(), -band.multiplier()]);
79        }
80        let mut lines = vec![Vec::with_capacity(to - from); offsets.len()];
81        for (i, value) in (from..to).zip(anchored_vwap(bars, &checkpoints, from..to)) {
82            let Some((vwap, sigma)) = value else { continue };
83            let x = (view.bar_to_x)((first + i) as f32);
84            for (line, k) in lines.iter_mut().zip(&offsets) {
85                line.push(Pos2::new(x, (view.price_to_y)(vwap + k * sigma)));
86            }
87        }
88        if lines[0].is_empty() {
89            return;
90        }
91
92        if config.bands[0].enabled {
93            super::super::fibonacci::paint_band(
94                painter,
95                &lines[1],
96                &lines[2],
97                VWAP_BAND_COLOR.gamma_multiply(0.05),
98            );
99        }
100        let vwap_line = lines.remove(0);
101        for line in lines {
102            painter.add(Shape::line(
103                line,
104                Stroke::new(stroke::HAIRLINE, VWAP_BAND_COLOR),
105            ));
106        }
107        painter.add(Shape::line(
108            vwap_line,
109            Stroke::new(self.stroke_width, self.color32()),
110        ));
111    }
112
113    /// Whether a visible profile drawing reads the chart's executions and finer bars.
114    pub(crate) fn reads_profile(&self) -> bool {
115        self.visible
116            && matches!(
117                self.tool_type,
118                DrawingToolType::FixedRangeVolumeProfile | DrawingToolType::AnchoredVolumeProfile
119            )
120    }
121
122    /// First index and bars between the anchors, or from the anchor to the last bar.
123    fn profile_span<'b>(&self, bars: &'b [Bar]) -> Option<(usize, &'b [Bar])> {
124        let anchor = |i: usize| self.chart_points.get(i).map(|p| p.bar_idx);
125        match self.tool_type {
126            DrawingToolType::FixedRangeVolumeProfile => bars_between(bars, anchor(0)?, anchor(1)?),
127            DrawingToolType::AnchoredVolumeProfile => bars_between(bars, anchor(0)?, f32::INFINITY),
128            _ => None,
129        }
130    }
131
132    /// The suggested history this visible profile reads, with the first open of its span.
133    pub(crate) fn profile_history(
134        &self,
135        bars: &[Bar],
136        timeframe: Timeframe,
137        resolutions: &[ProfileResolution],
138    ) -> Option<(StudyDataRequest, DateTime<Utc>)> {
139        if !self.reads_profile() {
140            return None;
141        }
142        let (_, span) = self.profile_span(bars)?;
143        let interval = finer_interval(span, timeframe, resolutions)?;
144        Some((
145            StudyDataRequest {
146                symbol: String::new(),
147                timeframe: Some(interval),
148                metric: None,
149            },
150            span.first()?.time,
151        ))
152    }
153
154    /// Renders the volume profile of the bars between the two anchors, with
155    /// the point of control and the value area.
156    pub(crate) fn render_fixed_range_volume_profile(
157        &self,
158        painter: &egui::Painter,
159        view: &ChartView,
160    ) {
161        let (&[p1, p2, ..], &[a, b, ..]) = (self.points.as_slice(), self.chart_points.as_slice())
162        else {
163            return;
164        };
165        if let Some((first, bars)) = bars_between(view.bars, a.bar_idx, b.bar_idx) {
166            let (left, right) = (p1.x.min(p2.x), p1.x.max(p2.x));
167            self.draw_volume_profile(painter, view, first, bars, left, right);
168        }
169    }
170
171    /// Renders the volume profile from the anchor bar to the last bar.
172    pub(crate) fn render_anchored_volume_profile(&self, painter: &egui::Painter, view: &ChartView) {
173        let (Some(&anchor), Some(anchor_at)) = (self.points.first(), self.chart_points.first())
174        else {
175            return;
176        };
177        if let Some((first, bars)) = bars_between(view.bars, anchor_at.bar_idx, f32::INFINITY) {
178            let right = (view.bar_to_x)((first + bars.len() - 1) as f32);
179            self.draw_volume_profile(painter, view, first, bars, anchor.x, right);
180        }
181    }
182
183    /// Draws the profile of `bars`, the span that starts at bar `first`, as
184    /// rows beside the first anchor, or at the pane's right edge when anchored,
185    /// with a tint over the measured bars' price and time range.
186    fn draw_volume_profile(
187        &self,
188        painter: &egui::Painter,
189        view: &ChartView,
190        first: usize,
191        bars: &[Bar],
192        left: f32,
193        right: f32,
194    ) {
195        let config = self.volume_profile.unwrap_or_default();
196        let profile = self.span_profile(view, first, bars);
197        let Some(profile) = profile.as_ref() else {
198            return;
199        };
200        crate::painting::volume_profile::paint_profile(
201            painter,
202            profile,
203            config,
204            self.color,
205            left..right,
206            ProfileStyle::Drawing {
207                anchored: self.tool_type == DrawingToolType::AnchoredVolumeProfile,
208            },
209            view.price_to_y,
210        );
211    }
212
213    /// The profile of `bars`, the span that starts at bar `first`: the executed volume at each
214    /// price when the view holds complete executions in the span, else the finer bars where they
215    /// cover chart bars, else estimated from the chart bars.
216    fn span_profile(
217        &self,
218        view: &ChartView,
219        first: usize,
220        bars: &[Bar],
221    ) -> Rc<Option<VolumeProfile>> {
222        if let (Some(flow), Some(start), Some(end)) = (view.executions, bars.first(), bars.last()) {
223            let executed = flow.profile(start.time, end.time);
224            let complete = flow
225                .complete_from()
226                .is_some_and(|from| from <= executed.time);
227            if let Some(profile) = VolumeProfile::from_executions(
228                &executed,
229                flow.price_step(),
230                flow.tick_size(),
231                complete,
232            ) {
233                return Rc::new(Some(profile));
234            }
235        }
236        let config = self.volume_profile.unwrap_or_default();
237        if let Some(profile) = finer_profile(view, config.rows(), config.row_size(), first, bars) {
238            return profile;
239        }
240        span_profile(view, config.rows(), config.row_size(), first, bars)
241    }
242
243    /// The profile that a fixed range or anchored volume profile shows over the view's bars,
244    /// with its [source](VolumeProfile::source): executed volume when
245    /// [`ChartView::executions`] holds complete bars in the selected span, those from
246    /// [`Footprint::complete_from`](crate::data::Footprint::complete_from), else the finer bars
247    /// of [`ChartView::finer`], else the bar estimate.
248    /// `None` for other tools, a drawing without its anchors, or an empty selection.
249    pub fn volume_profile_of(&self, view: &ChartView) -> Option<VolumeProfile> {
250        let (first, bars) = self.profile_span(view.bars)?;
251        self.span_profile(view, first, bars).as_ref().clone()
252    }
253}
254
255/// Running sums of volume, typical price times volume, and squared typical
256/// price times volume.
257#[derive(Clone, Copy, Default)]
258struct VwapSums {
259    volume: f64,
260    price_volume: f64,
261    square_volume: f64,
262}
263
264impl VwapSums {
265    fn add(&mut self, bar: &Bar) {
266        let price = bar.typical_price();
267        self.volume += bar.volume;
268        self.price_volume += price * bar.volume;
269        self.square_volume += price * price * bar.volume;
270    }
271
272    /// VWAP and the volume-weighted standard deviation around it; `None`
273    /// until volume trades.
274    fn value(&self) -> Option<(f64, f64)> {
275        (self.volume > 0.0).then(|| {
276            let vwap = self.price_volume / self.volume;
277            let variance = self.square_volume / self.volume - vwap * vwap;
278            (vwap, variance.max(0.0).sqrt())
279        })
280    }
281}
282
283/// The running sums at the start of the span and after every block of
284/// `VWAP_CHECKPOINT` bars of `prefix`, the span's leading whole blocks, so the
285/// VWAP at any bar takes fewer than `VWAP_CHECKPOINT` further additions.
286fn vwap_checkpoints(prefix: &[Bar]) -> Vec<VwapSums> {
287    let mut sums = VwapSums::default();
288    let mut checkpoints = vec![sums];
289    for block in prefix.chunks(VWAP_CHECKPOINT) {
290        block.iter().for_each(|bar| sums.add(bar));
291        checkpoints.push(sums);
292    }
293    checkpoints
294}
295
296/// VWAP from the first bar of the span, with its standard deviation, at each
297/// bar of the non-empty `range`, resuming from the checkpoint before it.
298fn anchored_vwap<'a>(
299    bars: &'a [Bar],
300    checkpoints: &[VwapSums],
301    range: Range<usize>,
302) -> impl Iterator<Item = Option<(f64, f64)>> + 'a {
303    let checkpoint = range.start / VWAP_CHECKPOINT;
304    let mut sums = checkpoints[checkpoint];
305    bars[checkpoint * VWAP_CHECKPOINT..range.start]
306        .iter()
307        .for_each(|bar| sums.add(bar));
308    bars[range].iter().map(move |bar| {
309        sums.add(bar);
310        sums.value()
311    })
312}
313
314/// Profile of `bars`, the span that starts at bar `first`, with `rows` rows.
315/// While the last bar, which may be forming, trades inside the range of the
316/// bars before it, their profile is reused, so a tick or a new bar costs one
317/// bar instead of the span.
318fn span_profile(
319    view: &ChartView,
320    rows: usize,
321    row_size: Option<f64>,
322    first: usize,
323    bars: &[Bar],
324) -> Rc<Option<VolumeProfile>> {
325    PROFILES.with_borrow_mut(|cache| {
326        view.cached(
327            cache,
328            (rows, row_size),
329            first,
330            bars,
331            |bars| VolumeProfile::of(bars, rows, row_size),
332            |profile, bar| profile.as_ref()?.with_bar(bar).map(Some),
333        )
334    })
335}
336
337/// Close of a bar of `timeframe`, saturating at the end of the calendar.
338fn close(open: DateTime<Utc>, timeframe: Timeframe) -> DateTime<Utc> {
339    open.checked_add_signed(timeframe.duration())
340        .unwrap_or(DateTime::<Utc>::MAX_UTC)
341}
342
343/// Suggested interval for a span measured from its first open through its last close.
344fn finer_interval(
345    span: &[Bar],
346    timeframe: Timeframe,
347    resolutions: &[ProfileResolution],
348) -> Option<Timeframe> {
349    let (first, last) = (span.first()?, span.last()?);
350    ProfileResolution::suggested(
351        resolutions,
352        timeframe,
353        close(last.time, timeframe) - first.time,
354    )
355}
356
357/// Replaces chart bars by the suggested finer bars inside them, when any are held in the span.
358fn finer_profile(
359    view: &ChartView,
360    rows: usize,
361    row_size: Option<f64>,
362    first: usize,
363    bars: &[Bar],
364) -> Option<Rc<Option<VolumeProfile>>> {
365    if view.finer.is_empty() {
366        return None;
367    }
368    let timeframe = view.timeframe?;
369    let interval = finer_interval(bars, timeframe, &view.instrument?.profile_resolutions)?;
370    let finer = view
371        .finer
372        .iter()
373        .find(|data| data.timeframe == interval)?
374        .bars;
375    let (start, last) = (bars.first()?.time, bars.last()?.time);
376    let end = view
377        .bars
378        .get(first + bars.len())
379        .map_or_else(|| close(last, timeframe), |next| next.time);
380    let at = finer.partition_point(|bar| bar.time < start);
381    let inside = &finer[at..at + finer[at..].partition_point(|bar| bar.time < end)];
382    if inside.is_empty() {
383        return None;
384    }
385    // shortcut: a host correction of a finer bar inside the span that keeps its fingerprint
386    // shows at the next span change; key on a history revision if feeds need it at once.
387    let key = (
388        rows,
389        row_size,
390        SpanFingerprint::of(at, inside, 0),
391        finer.as_ptr() as usize,
392    );
393    Some(FINER_PROFILES.with_borrow_mut(|cache| {
394        cache.get(key, first, bars, view.revision, |bars| {
395            VolumeProfile::of_finer(bars, end, inside, rows, row_size).0
396        })
397    }))
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::drawings::domain::DrawingToolType;
404    use crate::drawings::rendering::sample_bars;
405    use egui::Rect;
406
407    fn assert_close(got: f64, want: f64) {
408        assert!((got - want).abs() < 1e-9, "got {got}, want {want}");
409    }
410
411    /// The line keeps the drawing's width and only the enabled bands are drawn, band 1 filled.
412    #[test]
413    fn anchored_vwap_draws_its_line_and_enabled_bands_only() {
414        use crate::drawings::{ChartPoint, VwapBand, VwapConfig};
415        // From bar 1: V = 1, 2, 8; PV = 10, 22, 88; P²V = 100, 244, 970.
416        // VWAP = 10, 11, 11; deviation = 0, 1, 0.5.
417        let bars: Vec<_> = [(50.0, 0.0), (10.0, 1.0), (12.0, 1.0), (11.0, 6.0)]
418            .into_iter()
419            .enumerate()
420            .map(|(i, (price, volume))| {
421                let time = chrono::DateTime::from_timestamp(i as i64 * 60, 0).unwrap();
422                Bar::new(time, price, price, price, price, volume)
423            })
424            .collect();
425        let blue = Color32::from_rgb(41, 98, 255);
426        let mid = [100.0, 90.0, 90.0];
427        let mut other_bands = VwapConfig::default();
428        other_bands.bands[0].enabled = false;
429        other_bands.bands[1].enabled = true;
430        other_bands.bands[2] = VwapBand::new(true, 0.5).unwrap();
431        let mut no_bands = VwapConfig::default();
432        no_bands.bands[0].enabled = false;
433        for (name, anchor, config, width, fill, expected) in [
434            (
435                "default",
436                0.0,
437                None,
438                1.0,
439                true,
440                vec![
441                    ([100.0, 80.0, 85.0], VWAP_BAND_COLOR, 1.0),
442                    ([100.0, 100.0, 95.0], VWAP_BAND_COLOR, 1.0),
443                    (mid, blue, 1.0),
444                ],
445            ),
446            (
447                "bands 2 and 3",
448                0.0,
449                Some(other_bands),
450                3.0,
451                false,
452                vec![
453                    ([100.0, 70.0, 80.0], VWAP_BAND_COLOR, 1.0),
454                    ([100.0, 110.0, 100.0], VWAP_BAND_COLOR, 1.0),
455                    ([100.0, 85.0, 87.5], VWAP_BAND_COLOR, 1.0),
456                    ([100.0, 95.0, 92.5], VWAP_BAND_COLOR, 1.0),
457                    (mid, blue, 3.0),
458                ],
459            ),
460            (
461                "no bands",
462                0.0,
463                Some(no_bands),
464                1.0,
465                false,
466                vec![(mid, blue, 1.0)],
467            ),
468            ("past last bar", 4.0, None, 1.0, false, vec![]),
469        ] {
470            let mut drawing = Drawing::new(1, DrawingToolType::AnchoredVWAP);
471            drawing.color = [41, 98, 255, 255];
472            drawing.stroke_width = width;
473            drawing.vwap = config;
474            drawing.chart_points = vec![ChartPoint::new(anchor, 50.0)];
475            let view = ChartView {
476                instrument: None,
477                price_formatter: None,
478                time_formatter: None,
479                bar_seconds: 0,
480                bars: &bars,
481                revision: 0,
482                bar_to_x: &|b| 100.0 + 10.0 * b,
483                price_to_y: &|p| ((20.0 - p) * 10.0) as f32,
484                y_to_price: &|y| 20.0 - f64::from(y) / 10.0,
485                time_axis: true,
486                executions: None,
487                timeframe: None,
488                finer: &[],
489            };
490            let mut output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
491                let painter = egui::Painter::new(
492                    ui.ctx().clone(),
493                    egui::LayerId::background(),
494                    Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 600.0)),
495                );
496                drawing.render_anchored_vwap(&painter, &view);
497            });
498            let shapes = std::mem::take(&mut output.shapes);
499            output.drop_without_applying_deltas();
500            assert_eq!(shapes.len(), expected.len() + usize::from(fill), "{name}");
501            if fill {
502                let Shape::Mesh(mesh) = &shapes[0].shape else {
503                    panic!("{name}: band fill")
504                };
505                assert!(
506                    mesh.vertices
507                        .iter()
508                        .all(|v| v.color == Color32::from_rgba_premultiplied(4, 9, 4, 13)),
509                    "{name}"
510                );
511            }
512            for (shape, (ys, color, width)) in shapes[usize::from(fill)..].iter().zip(expected) {
513                let Shape::Path(line) = &shape.shape else {
514                    panic!("{name}: unexpected shape")
515                };
516                assert_eq!(line.stroke, Stroke::new(width, color).into(), "{name}");
517                let points: Vec<_> = ys
518                    .into_iter()
519                    .enumerate()
520                    .map(|(i, y)| Pos2::new(110.0 + 10.0 * i as f32, y))
521                    .collect();
522                assert_eq!(line.points, points, "{name}");
523            }
524        }
525    }
526
527    /// Reference: VWAP of (h + l + c) / 3 from bar 1, and the standard
528    /// deviation from its definition, sqrt(sum v (tp - vwap)^2 / sum v).
529    #[test]
530    fn anchored_vwap_matches_reference() {
531        let want = [
532            (11.666666666666664, 0.0),
533            (11.595238095238095, 0.08247860988423196),
534            (12.166666666666666, 0.6201736729460428),
535            (12.675925925925924, 0.9757431833967023),
536            (12.775641025641026, 0.8255305309085764),
537            (12.78395061728395, 0.8112060000493586),
538        ];
539        let bars = &sample_bars()[1..];
540        let got: Vec<_> = anchored_vwap(bars, &vwap_checkpoints(&[]), 0..bars.len()).collect();
541        assert_eq!(got.len(), want.len());
542        for (got, (vwap, sigma)) in got.into_iter().zip(want) {
543            let (got_vwap, got_sigma) = got.unwrap();
544            assert_close(got_vwap, vwap);
545            // One-pass variance cancels to about 1e-14, so its root is near 1e-7.
546            assert!(
547                (got_sigma - sigma).abs() < 1e-6,
548                "sigma {got_sigma}, want {sigma}"
549            );
550        }
551
552        // Windows that start inside a block or on a block boundary resume
553        // from a checkpoint: compare with the definition over a series three
554        // blocks long, the forming bar in the third block.
555        let long: Vec<Bar> = (0..3 * VWAP_CHECKPOINT)
556            .map(|i| {
557                let close = 100.0 + (i * 37 % 101) as f64 / 10.0;
558                let time = chrono::DateTime::from_timestamp(i as i64 * 60, 0).unwrap();
559                let volume = (1 + i % 7) as f64;
560                Bar::new(time, close, close + 1.0, close - 1.5, close, volume)
561            })
562            .collect();
563        let checkpoints = vwap_checkpoints(&long[..2 * VWAP_CHECKPOINT]);
564        for window in [
565            VWAP_CHECKPOINT + 5..VWAP_CHECKPOINT + 9,
566            2 * VWAP_CHECKPOINT..2 * VWAP_CHECKPOINT + 4,
567        ] {
568            let got = anchored_vwap(&long, &checkpoints, window.clone());
569            for (i, got) in window.zip(got) {
570                let prefix = &long[..=i];
571                let volume: f64 = prefix.iter().map(|b| b.volume).sum();
572                let vwap = prefix
573                    .iter()
574                    .map(|b| b.typical_price() * b.volume)
575                    .sum::<f64>()
576                    / volume;
577                let variance = prefix
578                    .iter()
579                    .map(|b| b.volume * (b.typical_price() - vwap).powi(2))
580                    .sum::<f64>()
581                    / volume;
582                let (got_vwap, got_sigma) = got.unwrap();
583                assert!(
584                    (got_vwap - vwap).abs() < 1e-9,
585                    "bar {i}: vwap {got_vwap}, want {vwap}"
586                );
587                assert!(
588                    (got_sigma - variance.sqrt()).abs() < 1e-6,
589                    "bar {i}: sigma {got_sigma}, want {}",
590                    variance.sqrt()
591                );
592            }
593        }
594    }
595
596    /// Rows retain their gaps on either scale, leave two thirds of the span clear,
597    /// and anchored rows grow left from the pane edge independently of the range tint.
598    #[test]
599    fn volume_profile_rows_are_drawn_on_either_scale_orientation() {
600        use crate::data::{ProfileDisplay, VolumeProfileConfig};
601        let bars = sample_bars();
602        let upright = |price: f64| (16.0 - price) as f32 * 50.0;
603        let inverted = |price: f64| (price - 8.0) as f32 * 50.0;
604        let clip = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 600.0));
605        for tool in [
606            DrawingToolType::FixedRangeVolumeProfile,
607            DrawingToolType::AnchoredVolumeProfile,
608        ] {
609            for price_to_y in [&upright as &dyn Fn(f64) -> f32, &inverted] {
610                for (width, row_count, row_height, display, va_lines) in [
611                    (400.0, 24, 11.5, ProfileDisplay::Total, false),
612                    (24.0, 24, 11.5, ProfileDisplay::Total, false),
613                    (24.0, 4, 74.0, ProfileDisplay::UpDown, true),
614                    (1200.0, 24, 11.5, ProfileDisplay::Total, false),
615                    (400.0, 1000, 0.15, ProfileDisplay::Total, false),
616                    (400.0, 4, 74.0, ProfileDisplay::UpDown, false),
617                    (400.0, 4, 74.0, ProfileDisplay::UpDown, true),
618                ] {
619                    let mut drawing = Drawing::new(1, tool);
620                    let mut config = VolumeProfileConfig::new(row_count, 0.7).unwrap();
621                    config.display = display;
622                    config.show_value_area_lines = va_lines;
623                    drawing.volume_profile = Some(config);
624                    let view = ChartView {
625                        price_formatter: None,
626                        time_formatter: None,
627                        bar_seconds: 0,
628                        instrument: None,
629                        bars: &bars,
630                        revision: 0,
631                        bar_to_x: &|x| x,
632                        price_to_y,
633                        y_to_price: &|y| {
634                            8.0 + f64::from(y - price_to_y(8.0))
635                                / f64::from(price_to_y(9.0) - price_to_y(8.0))
636                        },
637                        time_axis: true,
638                        executions: None,
639                        timeframe: None,
640                        finer: &[],
641                    };
642                    let mut output =
643                        egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
644                            let painter = egui::Painter::new(
645                                ui.ctx().clone(),
646                                egui::LayerId::background(),
647                                clip,
648                            );
649                            drawing.draw_volume_profile(
650                                &painter,
651                                &view,
652                                0,
653                                &bars,
654                                100.0,
655                                100.0 + width,
656                            );
657                        });
658                    let shapes = std::mem::take(&mut output.shapes);
659                    output.drop_without_applying_deltas();
660                    let rects: Vec<_> = shapes
661                        .iter()
662                        .filter_map(|c| match &c.shape {
663                            egui::Shape::Rect(r) => Some(r),
664                            _ => None,
665                        })
666                        .collect();
667                    let tint = rects[0];
668                    assert_eq!(
669                        tint.rect,
670                        Rect::from_min_max(Pos2::new(100.0, 50.0), Pos2::new(100.0 + width, 350.0))
671                    );
672                    assert!(tint.stroke.is_empty(), "range has no outline");
673                    assert!(
674                        tint.fill.a() > 0 && tint.fill.a() < 40,
675                        "range has a faint tint"
676                    );
677                    let rows = &rects[1..];
678                    let split = display == ProfileDisplay::UpDown;
679                    assert_eq!(rows.len(), row_count * if split { 2 } else { 1 });
680                    let anchored = tool == DrawingToolType::AnchoredVolumeProfile;
681                    let edge = if anchored { 800.0 } else { 100.0 };
682                    let mut widest = 0.0_f32;
683                    for row in rows.chunks(if split { 2 } else { 1 }) {
684                        let mut row_width = 0.0;
685                        for segment in row {
686                            assert!(segment.stroke.is_empty());
687                            assert!((segment.rect.height() - row_height).abs() < 1e-3);
688                            row_width += segment.rect.width();
689                        }
690                        assert!(
691                            ((if anchored {
692                                row[0].rect.right()
693                            } else {
694                                row[0].rect.left()
695                            }) - edge)
696                                .abs()
697                                < 1e-4
698                        );
699                        widest = widest.max(row_width);
700                    }
701                    let span_width = if anchored {
702                        width.min(clip.width())
703                    } else {
704                        width
705                    };
706                    assert!(
707                        (widest - span_width / 3.0).abs() < 1e-3,
708                        "widest row {widest}"
709                    );
710                    if split {
711                        // Four 1.5-price rows have totals 83 1/3, 350, 619 1/6, 397.5.
712                        // 70% of 1450 needs rows 0..=2; row 3 is outside the value area.
713                        for side in 0..2 {
714                            for row in 0..3 {
715                                assert!(rows[row * 2 + side].fill.a() > rows[6 + side].fill.a());
716                            }
717                        }
718                    }
719                    let mut lines = Vec::new();
720                    let mut labels = 0;
721                    let mut notes = 0;
722                    for shape in &shapes {
723                        match &shape.shape {
724                            egui::Shape::LineSegment { points, .. } => lines.push(points.to_vec()),
725                            egui::Shape::Path(path) => {
726                                assert_eq!(path.stroke.width, 2.0, "POC remains clear");
727                                lines.push(path.points.clone());
728                            }
729                            egui::Shape::Text(text) => {
730                                let label = text.galley.job.text.as_str();
731                                assert_ne!(label, "POC");
732                                if label == "Estimated from bars" {
733                                    notes += 1;
734                                    // The tint's top left corner on either scale, inset 3 points.
735                                    assert_eq!(text.pos, Pos2::new(103.0, 50.0));
736                                }
737                                if ["VAH", "VAL"].contains(&label) {
738                                    assert!(va_lines);
739                                    labels += 1;
740                                    let away = if price_to_y(15.0) < price_to_y(9.0) {
741                                        8.0
742                                    } else {
743                                        -8.0
744                                    };
745                                    let want = if label == "VAH" {
746                                        price_to_y(13.5) - away
747                                    } else {
748                                        price_to_y(9.0) + away
749                                    };
750                                    assert!(
751                                        (text.pos.y + text.galley.size().y / 2.0 - want).abs()
752                                            < 1.0
753                                    );
754                                }
755                            }
756                            _ => {}
757                        }
758                    }
759                    assert_eq!(lines.len(), if va_lines { 3 } else { 1 });
760                    // Labels need 50 px between the first anchor and the rows' far edge.
761                    let reach = if anchored { 800.0 - 100.0 } else { width };
762                    assert_eq!(labels, if va_lines && reach >= 50.0 { 2 } else { 0 });
763                    // The note fits in every span but the 24-point one.
764                    assert_eq!(notes, usize::from(width > 24.0));
765                    assert!(
766                        lines
767                            .iter()
768                            .all(|points| points.len() == 2 && points[0].y == points[1].y)
769                    );
770                }
771            }
772        }
773    }
774
775    /// The profile of a span that ends on the forming bar follows its ticks
776    /// and the next bar, extending the cached profile of the bars before it
777    /// while they trade inside its range and rebuilding the rows otherwise.
778    /// Either way it is the profile built from all the bars.
779    #[test]
780    fn span_profile_follows_the_forming_bar() {
781        let check = |bars: &[Bar], case: &str| {
782            let view = ChartView {
783                price_formatter: None,
784                time_formatter: None,
785                bar_seconds: 0,
786                instrument: None,
787                bars,
788                revision: 0,
789                bar_to_x: &|x| x,
790                price_to_y: &|price| price as f32,
791                y_to_price: &f64::from,
792                time_axis: true,
793                executions: None,
794                timeframe: None,
795                finer: &[],
796            };
797            let want = VolumeProfile::of(&bars[1..], 24, None);
798            assert_eq!(
799                *span_profile(&view, 24, None, 1, &bars[1..]),
800                want,
801                "{case}"
802            );
803        };
804        let mut bars = sample_bars();
805        check(&bars, "first frame");
806        for (high, low, close, volume) in [
807            (16.0, 12.0, 14.0, 150.0),
808            (14.0, 12.5, 13.5, 90.0),
809            (14.5, 12.0, 12.5, 130.0),
810        ] {
811            let forming = bars.last_mut().unwrap();
812            (forming.high, forming.low, forming.close, forming.volume) = (high, low, close, volume);
813            check(&bars, &format!("tick {low}..{high}"));
814        }
815        // The bar closes on a trade that was not drawn, and the next one opens.
816        bars.last_mut().unwrap().volume += 10.0;
817        let time = bars.last().unwrap().time + chrono::Duration::minutes(1);
818        bars.push(Bar::new(time, 13.0, 13.5, 12.5, 13.2, 20.0));
819        check(&bars, "new bar");
820    }
821
822    /// A selection over held executions shows their exact profile, as the public footprint
823    /// aggregation gives it: totals, aggressor sides, POC and value area. Moving an anchor
824    /// changes the selected span, and without executions the bar estimate is labelled as such.
825    #[test]
826    fn volume_profile_selects_executions_by_anchor() {
827        use crate::data::{Footprint, ProfileSource};
828        use crate::model::{BarClock, OrderSide, Tick, Timeframe};
829        let bars = sample_bars();
830        let mut flow = Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1).unwrap();
831        // Bar 1 trades 11 (4 sold), 12 (6 bought, 2 unknown); bar 2 trades 12 (3 bought).
832        let trades: Vec<_> = [
833            (1, 11.0, 4.0, Some(OrderSide::Sell)),
834            (1, 12.0, 6.0, Some(OrderSide::Buy)),
835            (1, 12.0, 2.0, None),
836            (2, 12.0, 3.0, Some(OrderSide::Buy)),
837        ]
838        .into_iter()
839        .map(|(bar, price, size, side)| Tick::new(bars[bar].time, price, size, side))
840        .collect();
841        for trade in &trades {
842            flow.push(trade).unwrap();
843        }
844        let view = |executions| ChartView {
845            price_formatter: None,
846            time_formatter: None,
847            bar_seconds: 0,
848            instrument: None,
849            bars: &bars,
850            revision: 0,
851            bar_to_x: &|x| x,
852            price_to_y: &|price| price as f32,
853            y_to_price: &f64::from,
854            time_axis: true,
855            executions,
856            timeframe: None,
857            finer: &[],
858        };
859        let mut drawing = Drawing::new(1, DrawingToolType::FixedRangeVolumeProfile);
860        let select = |drawing: &mut Drawing, first, last| {
861            drawing.chart_points = vec![
862                crate::drawings::domain::ChartPoint::new(first, 10.0),
863                crate::drawings::domain::ChartPoint::new(last, 10.0),
864            ];
865        };
866
867        select(&mut drawing, 0.0, 2.0);
868        let profile = drawing.volume_profile_of(&view(Some(&flow))).unwrap();
869        let exact = flow.profile(bars[0].time, bars[2].time);
870        assert_eq!(
871            profile.source(),
872            ProfileSource::Executions { complete: false }
873        );
874        assert_eq!((profile.low(), profile.high()), (10.5, 12.5));
875        assert_eq!(profile.volume(), [4.0, 11.0]);
876        assert_eq!(
877            (profile.up(), profile.down()),
878            ([0.0, 9.0].as_slice(), [4.0, 0.0].as_slice())
879        );
880        assert_eq!(profile.volume().iter().sum::<f64>(), exact.volume());
881        // 90% of 15 is 13.5, so the POC's 11 needs the 4 below it.
882        let (poc, (low, high)) = profile.poc_and_value_area(0.9).unwrap();
883        assert_eq!(exact.poc(), Some(12));
884        assert_eq!(exact.value_area(0.9), Some((11, 12)));
885        assert_eq!((poc, (low, high)), (1, (0, 1)));
886
887        // Moving the first anchor past bar 1 drops its executions and the lower row.
888        select(&mut drawing, 2.0, 2.0);
889        let profile = drawing.volume_profile_of(&view(Some(&flow))).unwrap();
890        assert_eq!((profile.low(), profile.volume()), (11.5, [3.0].as_slice()));
891
892        // A span with no executions, and a view without any, fall back to the estimate.
893        select(&mut drawing, 4.0, 5.0);
894        for executions in [Some(&flow), None] {
895            let profile = drawing.volume_profile_of(&view(executions)).unwrap();
896            assert_eq!(profile.source(), ProfileSource::Estimated);
897        }
898        // Executions held from the span's first bar are complete.
899        select(&mut drawing, 1.0, 2.0);
900        let profile = drawing.volume_profile_of(&view(Some(&flow))).unwrap();
901        assert_eq!(
902            profile.source(),
903            ProfileSource::Executions { complete: true }
904        );
905        // Once the store drops the sell of 4, bar 1 is cut: the same span counts bar 2 alone
906        // and is partial.
907        let mut cut = flow.clone();
908        cut.drop_through(trades[0].ts, &trades[1..]);
909        let profile = drawing.volume_profile_of(&view(Some(&cut))).unwrap();
910        assert_eq!(
911            profile.source(),
912            ProfileSource::Executions { complete: false }
913        );
914        assert_eq!(profile.volume(), [3.0]);
915    }
916
917    /// Finer bars replace only the chart bars they cover; executions still take precedence.
918    /// An anchored span includes a new intrabar even inside the last chart bar.
919    #[test]
920    fn volume_profile_spreads_finer_bars() {
921        use crate::data::{Footprint, ProfileSource, VolumeProfileConfig};
922        use crate::drawings::ChartPoint;
923        use crate::model::{BarClock, Instrument, OrderSide, Tick};
924        use crate::studies::StudyData;
925        let start: DateTime<Utc> = "2026-03-02T00:00:00Z".parse().unwrap();
926        let daily: Vec<_> = (0..3)
927            .map(|day| {
928                Bar::new(
929                    start + chrono::TimeDelta::days(day),
930                    100.0,
931                    110.0,
932                    100.0,
933                    105.0,
934                    100.0,
935                )
936            })
937            .collect();
938        let minute = |day, minutes, low, high, volume| {
939            Bar::new(
940                start + chrono::TimeDelta::days(day) + chrono::TimeDelta::minutes(minutes),
941                low,
942                high,
943                low,
944                (low + high) / 2.0,
945                volume,
946            )
947        };
948        let fine = [
949            minute(0, 870, 100.0, 101.0, 60.0),
950            minute(0, 871, 109.0, 110.0, 40.0),
951            minute(1, 870, 104.0, 106.0, 100.0),
952            minute(2, 870, 102.0, 103.0, 50.0),
953        ];
954        let mut flow = Footprint::new(BarClock::new(Timeframe::Day1), 1.0, 1).unwrap();
955        flow.push(&Tick::new(fine[0].time, 100.0, 5.0, Some(OrderSide::Buy)))
956            .unwrap();
957        let rows = |profile: &VolumeProfile| {
958            (
959                profile.low(),
960                profile.high(),
961                profile.volume().to_vec(),
962                profile.up().to_vec(),
963                profile.down().to_vec(),
964                profile.poc_and_value_area(0.7),
965            )
966        };
967        // Whole daily bars have volume 100 each. The first day has 60 + 40 finer volume,
968        // the second 100, and the new bar of day 3 contributes 50 in place of its daily 100.
969        for (name, anchored, last, count, max_days, executed, inputs, total) in [
970            ("fixed", false, 1.0, 3, 10, false, fine[..3].to_vec(), 200.0),
971            (
972                "partial",
973                false,
974                2.0,
975                3,
976                10,
977                false,
978                vec![
979                    fine[0].clone(),
980                    fine[1].clone(),
981                    fine[2].clone(),
982                    daily[2].clone(),
983                ],
984                300.0,
985            ),
986            (
987                "anchored before",
988                true,
989                2.0,
990                3,
991                10,
992                false,
993                vec![fine[2].clone(), daily[2].clone()],
994                200.0,
995            ),
996            (
997                "anchored after",
998                true,
999                2.0,
1000                4,
1001                10,
1002                false,
1003                fine[2..].to_vec(),
1004                150.0,
1005            ),
1006            (
1007                "span too long",
1008                false,
1009                1.0,
1010                3,
1011                1,
1012                false,
1013                daily[..2].to_vec(),
1014                200.0,
1015            ),
1016            ("executions", false, 1.0, 3, 10, true, Vec::new(), 5.0),
1017        ] {
1018            let instrument =
1019                Instrument::new("A").with_profile_resolutions([ProfileResolution::new(
1020                    Timeframe::Min1,
1021                    Some(chrono::TimeDelta::days(max_days)),
1022                )]);
1023            let history = [StudyData {
1024                request: StudyDataRequest {
1025                    symbol: "A".into(),
1026                    timeframe: Some(Timeframe::Min1),
1027                    metric: None,
1028                },
1029                bars: &fine[..count],
1030                clock: BarClock::new(Timeframe::Min1),
1031                instrument: None,
1032                timeframe: Timeframe::Min1,
1033            }];
1034            let view = ChartView {
1035                instrument: Some(&instrument),
1036                price_formatter: None,
1037                time_formatter: None,
1038                bar_seconds: 0,
1039                bars: &daily,
1040                revision: 0,
1041                bar_to_x: &|x| x,
1042                price_to_y: &|price| price as f32,
1043                y_to_price: &f64::from,
1044                time_axis: true,
1045                executions: executed.then_some(&flow),
1046                timeframe: Some(Timeframe::Day1),
1047                finer: &history,
1048            };
1049            let mut drawing = Drawing::new(
1050                1,
1051                if anchored {
1052                    DrawingToolType::AnchoredVolumeProfile
1053                } else {
1054                    DrawingToolType::FixedRangeVolumeProfile
1055                },
1056            );
1057            drawing.chart_points = if anchored {
1058                vec![ChartPoint::new(1.0, 100.0)]
1059            } else {
1060                vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(last, 100.0)]
1061            };
1062            let profile = drawing.volume_profile_of(&view).unwrap();
1063            if executed {
1064                assert_eq!(
1065                    profile.source(),
1066                    ProfileSource::Executions { complete: true },
1067                    "{name}"
1068                );
1069                assert_eq!((profile.low(), profile.high()), (99.5, 100.5));
1070                assert_eq!(profile.volume(), [5.0]);
1071            } else {
1072                let expected =
1073                    VolumeProfile::of(&inputs, VolumeProfileConfig::default().rows(), None)
1074                        .unwrap();
1075                assert_eq!(rows(&profile), rows(&expected), "{name}");
1076                assert_eq!(profile.source(), ProfileSource::Estimated, "{name}");
1077            }
1078            assert!(
1079                (profile.volume().iter().sum::<f64>() - total).abs() < 1e-9,
1080                "{name}"
1081            );
1082        }
1083    }
1084}