1use 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
17const VWAP_CHECKPOINT: usize = 1024;
19
20const VWAP_BAND_COLOR: Color32 = Color32::from_rgb(76, 175, 80);
22
23type ProfileCache = SpanCache<Option<VolumeProfile>, (usize, Option<f64>)>;
24
25type 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 static PROFILES: RefCell<ProfileCache> =
33 const { RefCell::new(SpanCache::new(MAX_CACHED_SPANS)) };
34 static FINER_PROFILES: RefCell<FinerCache> =
36 const { RefCell::new(SpanCache::new(MAX_CACHED_SPANS)) };
37}
38
39impl Drawing {
40 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 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 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 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 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 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 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 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 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 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 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 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#[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 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
283fn 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
296fn 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
314fn 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
337fn close(open: DateTime<Utc>, timeframe: Timeframe) -> DateTime<Utc> {
339 open.checked_add_signed(timeframe.duration())
340 .unwrap_or(DateTime::<Utc>::MAX_UTC)
341}
342
343fn 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
357fn 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 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 #[test]
413 fn anchored_vwap_draws_its_line_and_enabled_bands_only() {
414 use crate::drawings::{ChartPoint, VwapBand, VwapConfig};
415 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 #[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 assert!(
547 (got_sigma - sigma).abs() < 1e-6,
548 "sigma {got_sigma}, want {sigma}"
549 );
550 }
551
552 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 #[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 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 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 let reach = if anchored { 800.0 - 100.0 } else { width };
762 assert_eq!(labels, if va_lines && reach >= 50.0 { 2 } else { 0 });
763 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 #[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 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 #[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 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 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 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 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 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 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 #[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 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}