Skip to main content

egui_charts/studies/builtin/
volume_profile.rs

1//! Volume profiles scoped to the viewport, saved times or exchange calendar periods.
2//!
3//! A profile distributes the volume of each chart bar over the price rows between its low and
4//! high, which estimates where the volume traded. With a finer source interval the study asks
5//! the host for that history, through [`Indicator::data_requests`], and distributes each finer
6//! bar instead; a chart bar without finer bars keeps the estimate, so no volume is lost.
7//! While the source interval follows the chart, the study reads the interval the chart
8//! instrument suggests for the length of its profiles,
9//! [`Instrument::profile_resolutions`](crate::model::Instrument::profile_resolutions).
10//! [`ProfileInput`] tells how much of a profile the finer bars cover.
11//! The chart notes each profile "Estimated from bars", also when finer bars cover all of it:
12//! they narrow the estimate without making it exact.
13//! Where the chart holds the executions of a profile's bars, through
14//! [`Indicator::order_flow_inputs`], the profile sums them instead, in rows of whole ticks: the
15//! Height input rounded to whole ticks, or the traded ticks over Row count, rounded up and widened
16//! where rows counted from multiples of that height would exceed Row count.
17//! A profile whose executions start after its first bar sums those held and reports itself
18//! partial; one without them, or whose bars do not each hold their own executions, keeps the bars.
19//!
20//! Row fills stay at or below 35% opacity so price bars remain readable. The point of control
21//! and value-area guides keep their line colours and widths.
22
23use super::AutoAnchor;
24use super::anchor_period::{AnchorPeriod, ExchangeDays};
25use crate::data::{
26    FootprintBar, FootprintLevel, OrderFlowBars, OrderFlowInput, ProfileDisplay, StudyProfile,
27    VolumeProfile, VolumeProfileConfig,
28};
29use crate::model::{Bar, BarClock, Instrument, ProfileResolution, Timeframe};
30use crate::studies::{
31    Indicator, IndicatorValues, InputKind, InputValue, StudyData, StudyDataRequest, StudyInput,
32};
33use chrono::{DateTime, TimeDelta, Utc};
34use egui::Color32;
35use std::cell::RefCell;
36use std::collections::BTreeMap;
37use std::ops::{Bound, Range};
38use std::sync::Arc;
39
40/// What a volume profile was built from.
41#[derive(Clone, Debug, PartialEq)]
42pub struct ProfileInput {
43    /// The chart bars the profile covers.
44    pub bars: Range<usize>,
45    /// The share of those bars, from 0 to 1, whose volume comes from finer bars of the source
46    /// interval. The volume of the other bars is an estimate spread over each bar's high to
47    /// low range, so 0 marks a profile that is an estimate throughout, unless its
48    /// [`source`](crate::data::VolumeProfile::source) is its executions, which take no finer bars.
49    pub coverage: f64,
50}
51
52#[derive(Clone, Copy, PartialEq)]
53enum Kind {
54    Visible,
55    Fixed,
56    Session,
57    Periodic,
58    Anchored,
59    Auto,
60}
61
62#[derive(Clone)]
63struct ProfileStudy {
64    kind: Kind,
65    rows: usize,
66    value_area: f64,
67    row_size: f64,
68    display: ProfileDisplay,
69    start_time: f64,
70    end_time: f64,
71    period: AnchorPeriod,
72    anchor: AutoAnchor,
73    length: usize,
74    interval: Timeframe,
75    /// Bar calendar, from `set_bar_clock`, by which each bar's executions are found.
76    clock: BarClock,
77    source: Option<Timeframe>,
78    /// Read the interval the instrument suggests while `source` follows the chart.
79    feed_source: bool,
80    adaptive: bool,
81    tick_size: Option<f64>,
82    resolutions: Vec<ProfileResolution>,
83    /// The finer interval and data start stated before the chart emptied the profile to wait
84    /// for its full recompute, still stated until it has bars again.
85    held: Option<(Option<Timeframe>, Option<DateTime<Utc>>)>,
86    /// Chart bars in view, from `set_visible_range`.
87    view: Range<usize>,
88    days: ExchangeDays,
89    data: Vec<Bar>,
90    fine: Vec<Bar>,
91    /// Each bar's executions by price, one level per tick. Clones share the chart-fed source.
92    flow: OrderFlowInput,
93    ranges: Vec<Range<usize>>,
94    cache: RefCell<Vec<CachedProfile>>,
95    values: IndicatorValues,
96    colors: Vec<Color32>,
97    visible: bool,
98}
99
100#[derive(Clone)]
101struct CachedProfile {
102    plot: StudyProfile,
103    opening: Option<VolumeProfile>,
104    coverage: f64,
105    dirty: bool,
106}
107
108const PERIODS: [AnchorPeriod; 7] = [
109    AnchorPeriod::Session,
110    AnchorPeriod::Week,
111    AnchorPeriod::Month,
112    AnchorPeriod::Quarter,
113    AnchorPeriod::Year,
114    AnchorPeriod::Decade,
115    AnchorPeriod::Century,
116];
117const ANCHORS: [AutoAnchor; 11] = [
118    AutoAnchor::Auto,
119    AutoAnchor::HighestHigh,
120    AutoAnchor::LowestLow,
121    AutoAnchor::HighestVolume,
122    AutoAnchor::Session,
123    AutoAnchor::Week,
124    AutoAnchor::Month,
125    AutoAnchor::Quarter,
126    AutoAnchor::Year,
127    AutoAnchor::Decade,
128    AutoAnchor::Century,
129];
130const DISPLAYS: [ProfileDisplay; 3] = [
131    ProfileDisplay::Total,
132    ProfileDisplay::UpDown,
133    ProfileDisplay::Delta,
134];
135
136/// UTC times in Inputs, with a fractional second only when one is present.
137const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
138
139/// Saved milliseconds as text, empty for the unset endpoints.
140pub(crate) fn time_text(ms: f64) -> String {
141    if ms.abs() >= 8e15 {
142        return String::new();
143    }
144    DateTime::from_timestamp_millis(ms as i64)
145        .map_or_else(String::new, |time| time.format(TIME_FORMAT).to_string())
146}
147
148/// UTC date and time to the minute or finer, or `unset` for empty text.
149fn parse_time(text: &str, unset: f64) -> Result<f64, &'static str> {
150    if text.is_empty() {
151        return Ok(unset);
152    }
153    chrono::NaiveDateTime::parse_from_str(text, TIME_FORMAT)
154        .or_else(|_| chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M"))
155        .map(|time| time.and_utc().timestamp_millis() as f64)
156        .map_err(|_| "time must read YYYY-MM-DD HH:MM:SS")
157}
158
159impl ProfileStudy {
160    fn new(kind: Kind, rows: usize) -> Self {
161        Self {
162            kind,
163            rows: rows.max(1),
164            value_area: 0.7,
165            row_size: 0.0,
166            display: ProfileDisplay::UpDown,
167            start_time: -8e15,
168            end_time: 8e15,
169            period: AnchorPeriod::Week,
170            anchor: AutoAnchor::Auto,
171            length: 100,
172            interval: Timeframe::Min1,
173            clock: BarClock::new(Timeframe::Min1),
174            source: None,
175            feed_source: true,
176            adaptive: false,
177            tick_size: None,
178            resolutions: Vec::new(),
179            held: None,
180            view: 0..0,
181            days: ExchangeDays::default(),
182            data: Vec::new(),
183            fine: Vec::new(),
184            flow: OrderFlowInput::footprint(),
185            ranges: Vec::new(),
186            cache: RefCell::new(Vec::new()),
187            values: IndicatorValues::new(),
188            colors: vec![
189                Color32::from_rgb(41, 98, 255),
190                Color32::from_rgba_unmultiplied(51, 74, 212, 89),
191                Color32::from_rgba_unmultiplied(214, 140, 35, 89),
192                Color32::from_rgb(51, 74, 212),
193                Color32::from_rgb(214, 140, 35),
194            ],
195            visible: true,
196        }
197    }
198
199    fn inputs(&self) -> Vec<StudyInput> {
200        let mut inputs = vec![
201            StudyInput::new(
202                "row_count",
203                "Row count",
204                InputValue::Length(self.rows),
205                InputKind::Length(1, 1000),
206            ),
207            StudyInput::new(
208                "value_area",
209                "Value area (%)",
210                InputValue::Number(self.value_area * 100.0),
211                InputKind::Number(0.01, 100.0),
212            ),
213            StudyInput::new(
214                "row_size",
215                "Height (0 = equal)",
216                InputValue::Number(self.row_size),
217                InputKind::Number(0.0, f64::MAX),
218            ),
219            StudyInput::new(
220                "display",
221                "Volume",
222                InputValue::Choice(DISPLAYS.iter().position(|d| *d == self.display).unwrap()),
223                InputKind::Choice(vec!["Total".into(), "Up / down".into(), "Delta".into()]),
224            ),
225            StudyInput::new(
226                "source_interval",
227                "Source interval",
228                InputValue::Timeframe(self.source),
229                InputKind::Timeframe,
230            ),
231            StudyInput::new(
232                "feed_source",
233                "Feed suggested interval",
234                InputValue::Toggle(self.feed_source),
235                InputKind::Toggle,
236            ),
237        ];
238        if matches!(self.kind, Kind::Fixed | Kind::Anchored) {
239            inputs.push(StudyInput::new(
240                "start_time",
241                "Start (YYYY-MM-DD HH:MM:SS UTC)",
242                InputValue::Text(time_text(self.start_time)),
243                InputKind::Text,
244            ));
245        }
246        if self.kind == Kind::Fixed {
247            inputs.push(StudyInput::new(
248                "end_time",
249                "End (YYYY-MM-DD HH:MM:SS UTC)",
250                InputValue::Text(time_text(self.end_time)),
251                InputKind::Text,
252            ));
253        }
254        if self.kind == Kind::Periodic {
255            inputs.push(StudyInput::new(
256                "period",
257                "Period",
258                InputValue::Choice(PERIODS.iter().position(|p| *p == self.period).unwrap()),
259                InputKind::Choice(PERIODS.iter().map(|p| format!("{p:?}")).collect()),
260            ));
261        }
262        if self.kind == Kind::Auto {
263            inputs.push(StudyInput::new(
264                "anchor",
265                "Anchor",
266                InputValue::Choice(ANCHORS.iter().position(|a| *a == self.anchor).unwrap()),
267                InputKind::Choice(ANCHORS.iter().map(|a| format!("{a:?}")).collect()),
268            ));
269            inputs.push(StudyInput::new(
270                "length",
271                "Lookback bars",
272                InputValue::Length(self.length),
273                InputKind::Length(1, usize::MAX),
274            ));
275        }
276        if self.kind == Kind::Session {
277            inputs.push(StudyInput::new(
278                "adaptive_rows",
279                "Adapt rows to zoom",
280                InputValue::Toggle(self.adaptive),
281                InputKind::Toggle,
282            ));
283        }
284        inputs
285    }
286
287    fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
288        if !self
289            .inputs()
290            .iter()
291            .any(|input| input.id == id && input.accepts(&value))
292        {
293            return Err(crate::studies::StudyError::rejected(&self.inputs(), id));
294        }
295        match (id, value) {
296            ("row_count", InputValue::Length(v)) => self.rows = v,
297            ("value_area", InputValue::Number(v)) => self.value_area = v / 100.0,
298            ("row_size", InputValue::Number(v)) => self.row_size = v,
299            ("display", InputValue::Choice(v)) => self.display = DISPLAYS[v],
300            ("source_interval", InputValue::Timeframe(v)) => self.source = v,
301            ("feed_source", InputValue::Toggle(v)) => self.feed_source = v,
302            ("adaptive_rows", InputValue::Toggle(v)) => self.adaptive = v,
303            ("start_time", InputValue::Text(text)) => {
304                self.start_time = parse_time(&text, -8e15).map_err(|reason| {
305                    crate::studies::StudyError::InvalidInput {
306                        id: id.into(),
307                        reason: reason.into(),
308                    }
309                })?
310            }
311            ("end_time", InputValue::Text(text)) => {
312                self.end_time = parse_time(&text, 8e15).map_err(|reason| {
313                    crate::studies::StudyError::InvalidInput {
314                        id: id.into(),
315                        reason: reason.into(),
316                    }
317                })?
318            }
319            ("period", InputValue::Choice(v)) => self.period = PERIODS[v],
320            ("anchor", InputValue::Choice(v)) => self.anchor = ANCHORS[v],
321            ("length", InputValue::Length(v)) => self.length = v,
322            _ => return Err(crate::studies::StudyError::invalid_input(id)),
323        }
324        self.values.clear();
325        self.data.clear();
326        self.held = None;
327        self.fine.clear();
328        self.ranges.clear();
329        self.cache.get_mut().clear();
330        Ok(())
331    }
332
333    /// An explicit interval shorter than the chart's, or the shortest suggested interval
334    /// that serves the profile length while the source follows the chart; the held one while
335    /// emptied for a recompute.
336    fn finer(&self) -> Option<Timeframe> {
337        let shorter = |source: &Timeframe| {
338            !source.is_trade_based() && source.duration_ms() < self.interval.duration_ms()
339        };
340        // The chart interval may have changed since.
341        if let Some((timeframe, _)) = self.held.filter(|_| self.data.is_empty()) {
342            return timeframe.filter(shorter);
343        }
344        if self.source.is_some() || !self.feed_source || self.resolutions.is_empty() {
345            return self.source.filter(shorter);
346        }
347        ProfileResolution::suggested(&self.resolutions, self.interval, self.span()?)
348    }
349
350    /// Profile length from first open to last close: visible bars for a visible profile,
351    /// the longest range otherwise.
352    fn span(&self) -> Option<TimeDelta> {
353        let span = |range: &Range<usize>| {
354            let end = range.end.min(self.data.len());
355            let first = self.data.get(range.start).filter(|_| range.start < end)?;
356            Some(self.close(self.data[end - 1].time) - first.time)
357        };
358        if self.kind == Kind::Visible {
359            span(&self.view)
360        } else {
361            self.ranges.iter().filter_map(span).max()
362        }
363    }
364
365    /// The close of the chart bar that opens at `open`, saturating past the calendar's end.
366    fn close(&self, open: DateTime<Utc>) -> DateTime<Utc> {
367        open.checked_add_signed(self.interval.duration())
368            .unwrap_or(DateTime::<Utc>::MAX_UTC)
369    }
370
371    fn data_requests(&self) -> Vec<StudyDataRequest> {
372        self.finer()
373            .map(|timeframe| StudyDataRequest {
374                symbol: String::new(),
375                timeframe: Some(timeframe),
376                metric: None,
377            })
378            .into_iter()
379            .collect()
380    }
381
382    /// The chart bar ranges of the profiles in `visible`: the clamped view for a visible
383    /// profile, the ranges overlapping it otherwise.
384    fn ranges_in(&self, visible: Range<usize>) -> impl Iterator<Item = Range<usize>> + '_ {
385        let visible = visible.start.min(self.data.len())..visible.end.min(self.data.len());
386        let own = (self.kind == Kind::Visible && !visible.is_empty()).then(|| visible.clone());
387        // A visible profile has no ranges of its own.
388        let overlapping = self.ranges.iter().filter(move |range| {
389            !visible.is_empty() && range.start < visible.end && range.end > visible.start
390        });
391        own.into_iter().chain(overlapping.cloned())
392    }
393
394    /// The open of the first chart bar a profile in view reads, from which its finer bars are
395    /// needed; the open of the newest bar with no profile in view, the held start while emptied
396    /// for a recompute, and `None` without bars otherwise.
397    fn data_start(&self) -> Option<DateTime<Utc>> {
398        if self.data.is_empty() {
399            return self.held.and_then(|(_, start)| start);
400        }
401        self.ranges_in(self.view.clone())
402            .map(|range| range.start)
403            .min()
404            .map_or(self.data.last(), |at| self.data.get(at))
405            .map(|bar| bar.time)
406    }
407
408    /// Takes the finer bars, replacing all of them from bar 0 and from the first changed chart
409    /// bar on otherwise, so a reply for another interval is dropped.
410    fn update_data(&mut self, data: &[Bar], from: usize, sources: &[StudyData<'_>]) {
411        let wanted = self.finer();
412        match sources
413            .iter()
414            .find(|source| Some(source.timeframe) == wanted)
415        {
416            Some(source) if from > 0 && !data.is_empty() => {
417                let cut = data[from.min(data.len() - 1)].time;
418                self.fine
419                    .truncate(self.fine.partition_point(|bar| bar.time < cut));
420                let skip = source.bars.partition_point(|bar| bar.time < cut);
421                self.fine.extend_from_slice(&source.bars[skip..]);
422            }
423            Some(source) => self.fine = source.bars.to_vec(),
424            None => self.fine.clear(),
425        }
426        self.update(data, from);
427    }
428
429    /// What each profile in `visible` was built from.
430    fn profile_inputs(&self, visible: Range<usize>) -> Vec<ProfileInput> {
431        self.profiles(visible)
432            .into_iter()
433            .map(|profile| ProfileInput {
434                coverage: self
435                    .cache
436                    .borrow()
437                    .iter()
438                    .find(|cached| cached.plot.bars == profile.bars)
439                    .map_or(0.0, |cached| cached.coverage),
440                bars: profile.bars,
441            })
442            .collect()
443    }
444
445    /// The executions the chart holds from the bar before chart bar `start` on, while the source
446    /// opens its bars by this study's calendar; executions keyed by another calendar, as the
447    /// chart's are while the study runs on another interval, cannot be assigned to these bars.
448    fn held_executions(&self, start: usize) -> Option<OrderFlowBars<FootprintBar>> {
449        if self.flow.footprint_clock().as_ref() != Some(&self.clock) {
450            return None;
451        }
452        let from = start
453            .checked_sub(1)
454            .map_or(DateTime::<Utc>::MIN_UTC, |before| {
455                self.clock.bar_open(self.data[before].time)
456            });
457        // shortcut: viewport queries copy up to 110,000 held rows; retain the copy by revision
458        // if panning shows the cost.
459        Some(self.flow.footprints_from(from))
460    }
461
462    /// The executions of chart bars `range` in `flow`, merged per tick, and whether `flow` holds
463    /// all of them. `None` unless it holds some and every bar that can hold them is a bar of
464    /// `flow`: stamped at its open, in a slot of its own and apart from the bars beside the
465    /// range, with no executions held in a slot that no bar of the range opens. Bars of
466    /// price-based chart types and bars the chart lacks so keep the estimate.
467    fn executions_of(
468        &self,
469        range: &Range<usize>,
470        flow: &OrderFlowBars<FootprintBar>,
471    ) -> Option<(FootprintBar, bool)> {
472        let open = |bar: &Bar| self.clock.bar_open(bar.time);
473        let (start, end) = (
474            open(&self.data[range.start]),
475            open(&self.data[range.end - 1]),
476        );
477        let before = range.start.checked_sub(1).map(|i| open(&self.data[i]));
478        // A bar beside the range in one of its slots shares that slot's executions.
479        if start > end
480            || before.is_some_and(|before| before >= start)
481            || self
482                .data
483                .get(range.end)
484                .is_some_and(|after| open(after) <= end)
485        {
486            return None;
487        }
488        let lower = before.map_or(Bound::Unbounded, Bound::Excluded);
489        let held = flow.bars.range((lower, Bound::Included(end)));
490        let first = *held.clone().next()?.0;
491        // Bars stamped before the first held bar hold none of the executions.
492        let from = range.start + self.data[range.clone()].partition_point(|bar| bar.time < first);
493        let mut opens = Vec::with_capacity(range.end - from);
494        for bar in &self.data[from..range.end] {
495            let open = open(bar);
496            if open != bar.time || opens.last().is_some_and(|&last| last >= open) {
497                return None;
498            }
499            opens.push(open);
500        }
501        let mut opens = opens.into_iter().peekable();
502        let mut merged = FootprintBar::empty(start);
503        for (&time, bar) in held {
504            while opens.next_if(|&open| open < time).is_some() {}
505            // Executions in a slot that no bar of the range opens belong to bars it lacks.
506            if opens.peek() != Some(&time) {
507                return None;
508            }
509            merged.absorb(bar);
510        }
511        // During a replay the bar containing the cutoff is absent when its executions before the
512        // cutoff are not known.
513        let unknown = flow.cutoff.is_some_and(|cutoff| {
514            end <= cutoff
515                && self.clock.next_open(end).is_none_or(|next| cutoff < next)
516                && !flow.bars.contains_key(&end)
517        });
518        let complete = flow.complete_from.is_some_and(|from| from <= start) && !unknown;
519        Some((merged, complete))
520    }
521
522    /// The profile of chart bars `range` from the executions in `flow`, in rows of whole ticks
523    /// (`ticks_per_row`). `None` without them ([`Self::executions_of`]), with a volume that is
524    /// not finite, or past 10,000 rows.
525    fn executed_profile(
526        &self,
527        range: &Range<usize>,
528        config: &VolumeProfileConfig,
529        flow: &OrderFlowBars<FootprintBar>,
530    ) -> Option<VolumeProfile> {
531        let tick = flow.tick_size?;
532        let (bar, complete) = self.executions_of(range, flow)?;
533        let (&first, &last) = (bar.levels.keys().next()?, bar.levels.keys().next_back()?);
534        if !bar.volume().is_finite() {
535            return None;
536        }
537        let ticks = ticks_per_row(config, tick, first, last)?;
538        VolumeProfile::from_executions(&grouped(&bar, ticks), ticks as f64 * tick, tick, complete)
539    }
540
541    fn period_ranges(&self, period: AnchorPeriod) -> Vec<Range<usize>> {
542        let mut ranges = Vec::new();
543        let mut previous = None;
544        let mut start = 0;
545        for (i, day) in self.days.of(&self.data).into_iter().enumerate() {
546            let key = period.key(day);
547            if previous.is_some_and(|previous| previous != key) {
548                ranges.push(start..i);
549                start = i;
550            }
551            previous = Some(key);
552        }
553        if start < self.data.len() {
554            ranges.push(start..self.data.len());
555        }
556        ranges
557    }
558
559    fn calculate(&mut self, data: &[Bar]) {
560        self.data = data.to_vec();
561        self.values = IndicatorValues::missing(data.len());
562        self.cache.get_mut().clear();
563        self.refresh_ranges();
564    }
565
566    fn update(&mut self, data: &[Bar], from: usize) {
567        if data.is_empty() && !self.data.is_empty() {
568            // Keeps requesting what it read, so the chart keeps those bars for the recompute.
569            self.held = Some((self.finer(), self.data_start()));
570        }
571        let from = from.min(data.len()).min(self.data.len());
572        let times_changed = self.data.len() != data.len()
573            || self.data[from..]
574                .iter()
575                .zip(&data[from..])
576                .any(|(a, b)| a.time != b.time);
577        self.data.truncate(from);
578        self.data.extend_from_slice(&data[from..]);
579        self.values = IndicatorValues::missing(data.len());
580        self.cache.get_mut().retain_mut(|cached| {
581            if cached.plot.bars.end.saturating_sub(1) > from {
582                return false;
583            }
584            cached.dirty |= cached.plot.bars.end > from;
585            true
586        });
587        if times_changed
588            || (self.kind == Kind::Auto
589                && matches!(
590                    self.anchor,
591                    AutoAnchor::HighestHigh | AutoAnchor::LowestLow | AutoAnchor::HighestVolume
592                ))
593        {
594            self.refresh_ranges();
595        }
596    }
597
598    fn refresh_ranges(&mut self) {
599        let data = &self.data;
600        self.ranges = match self.kind {
601            Kind::Visible => Vec::new(),
602            Kind::Fixed | Kind::Anchored => {
603                let start = data
604                    .partition_point(|bar| (bar.time.timestamp_millis() as f64) < self.start_time);
605                let end = if self.kind == Kind::Fixed {
606                    data.partition_point(|bar| {
607                        (bar.time.timestamp_millis() as f64) <= self.end_time
608                    })
609                } else {
610                    data.len()
611                };
612                if start < end {
613                    std::iter::once(start..end).collect()
614                } else {
615                    Vec::new()
616                }
617            }
618            Kind::Session => self.period_ranges(AnchorPeriod::Session),
619            Kind::Periodic => self.period_ranges(self.period),
620            Kind::Auto => {
621                let period = match self.anchor {
622                    AutoAnchor::Auto => {
623                        let day = Timeframe::Day1.duration_ms();
624                        Some(match self.interval.duration_ms() {
625                            ms if ms < day => AnchorPeriod::Session,
626                            ms if ms <= day => AnchorPeriod::Month,
627                            ms if ms <= 10 * day => AnchorPeriod::Quarter,
628                            ms if ms <= 60 * day => AnchorPeriod::Year,
629                            _ => AnchorPeriod::Decade,
630                        })
631                    }
632                    AutoAnchor::HighestHigh | AutoAnchor::LowestLow | AutoAnchor::HighestVolume => {
633                        None
634                    }
635                    _ => ANCHORS
636                        .iter()
637                        .position(|a| *a == self.anchor)
638                        .map(|i| PERIODS[i - 4]),
639                };
640                if let Some(period) = period {
641                    self.period_ranges(period).pop().into_iter().collect()
642                } else {
643                    let metric = |bar: &Bar| match self.anchor {
644                        AutoAnchor::HighestHigh => bar.high,
645                        AutoAnchor::LowestLow => -bar.low,
646                        _ => bar.volume,
647                    };
648                    (data.len().saturating_sub(self.length)..data.len())
649                        .filter(|&i| metric(&data[i]).is_finite())
650                        .max_by(|&a, &b| metric(&data[a]).total_cmp(&metric(&data[b])))
651                        .map(|start| start..data.len())
652                        .into_iter()
653                        .collect()
654                }
655            }
656        };
657    }
658
659    /// One row height for the chart-bar envelope of every session in view, rounded up
660    /// to 1, 2 or 5 times a power of ten ticks, or of price without a tick size.
661    fn adaptive_height(&self, ranges: &[Range<usize>]) -> Option<f64> {
662        let (low, high) = ranges
663            .iter()
664            .filter_map(|range| self.data.get(range.clone()))
665            .flatten()
666            .filter(|bar| bar.low.is_finite() && bar.high.is_finite() && bar.high >= bar.low)
667            .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), bar| {
668                (low.min(bar.low), high.max(bar.high))
669            });
670        let raw = (high - low) / self.rows as f64;
671        if !(raw.is_finite() && raw > 0.0) {
672            return None;
673        }
674        match self.tick_size {
675            Some(tick) => nice_ceil((raw / tick).max(1.0)).map(|ticks| tick * ticks),
676            None => nice_ceil(raw),
677        }
678    }
679
680    fn profiles(&self, visible: Range<usize>) -> Vec<StudyProfile> {
681        let ranges: Vec<_> = self.ranges_in(visible).collect();
682        if ranges.is_empty() {
683            return Vec::new();
684        }
685        let Some(mut config) = VolumeProfileConfig::new(self.rows, self.value_area)
686            .and_then(|c| c.with_row_size((self.row_size > 0.0).then_some(self.row_size)))
687        else {
688            return Vec::new();
689        };
690        if self.adaptive
691            && let Some(height) = self.adaptive_height(&ranges)
692        {
693            config = config.with_row_size(Some(height)).unwrap_or(config);
694        }
695        config.display = self.display;
696        config.up_color = self.colors[1].to_srgba_unmultiplied();
697        config.down_color = self.colors[2].to_srgba_unmultiplied();
698        config.value_area_colors = Some([
699            self.colors[3].to_srgba_unmultiplied(),
700            self.colors[4].to_srgba_unmultiplied(),
701        ]);
702        let mut cache = self.cache.borrow_mut();
703        let mut profiles = Vec::new();
704        // Copied once, from the bar before the first profile built here on.
705        let mut held: Option<Option<OrderFlowBars<FootprintBar>>> = None;
706        for range in ranges {
707            if let Some(plot) = cache.iter().find(|cached| {
708                cached.plot.bars == range && cached.plot.config == config && !cached.dirty
709            }) {
710                profiles.push(plot.plot.clone());
711            } else if let Some(profile) = held
712                .get_or_insert_with(|| self.held_executions(range.start))
713                .as_ref()
714                .and_then(|flow| self.executed_profile(&range, &config, flow))
715            {
716                let plot = StudyProfile {
717                    bars: range.clone(),
718                    profile: Arc::new(profile),
719                    config,
720                    color: self.colors[0].to_srgba_unmultiplied(),
721                    right_aligned: self.kind == Kind::Visible,
722                };
723                profiles.push(plot.clone());
724                cache.retain(|cached| cached.plot.bars != range);
725                cache.push(CachedProfile {
726                    plot,
727                    opening: None,
728                    coverage: 0.0,
729                    dirty: false,
730                });
731            } else if self.finer().is_some() && !self.fine.is_empty() {
732                let end = self.data.get(range.end).map_or_else(
733                    || self.close(self.data[range.end - 1].time),
734                    |next| next.time,
735                );
736                let (profile, covered) = VolumeProfile::of_finer(
737                    &self.data[range.clone()],
738                    end,
739                    &self.fine,
740                    config.rows(),
741                    config.row_size(),
742                );
743                let coverage = covered as f64 / range.len() as f64;
744                let Some(profile) = profile else {
745                    continue;
746                };
747                let plot = StudyProfile {
748                    bars: range.clone(),
749                    profile: Arc::new(profile),
750                    config,
751                    color: self.colors[0].to_srgba_unmultiplied(),
752                    right_aligned: self.kind == Kind::Visible,
753                };
754                profiles.push(plot.clone());
755                cache.retain(|cached| cached.plot.bars != range);
756                cache.push(CachedProfile {
757                    plot,
758                    opening: None,
759                    coverage,
760                    dirty: false,
761                });
762            } else {
763                let opening = cache
764                    .iter()
765                    .filter(|cached| {
766                        cached.plot.bars.start == range.start
767                            && cached.plot.bars.end <= range.end
768                            && cached.plot.config == config
769                    })
770                    .max_by_key(|cached| cached.plot.bars.end)
771                    .and_then(|cached| {
772                        let mut opening = cached.opening.clone()?;
773                        for bar in &self.data[cached.plot.bars.end - 1..range.end - 1] {
774                            opening = opening.with_bar(bar)?;
775                        }
776                        Some(opening)
777                    })
778                    .or_else(|| {
779                        VolumeProfile::from_bars(&self.data[range.start..range.end - 1], config)
780                    });
781                let profile = opening
782                    .as_ref()
783                    .and_then(|p| p.with_bar(&self.data[range.end - 1]))
784                    .or_else(|| VolumeProfile::from_bars(&self.data[range.clone()], config));
785                let Some(profile) = profile else {
786                    continue;
787                };
788                let plot = StudyProfile {
789                    bars: range.clone(),
790                    profile: Arc::new(profile),
791                    config,
792                    color: self.colors[0].to_srgba_unmultiplied(),
793                    right_aligned: self.kind == Kind::Visible,
794                };
795                profiles.push(plot.clone());
796                cache.retain(|cached| cached.plot.bars != range);
797                cache.push(CachedProfile {
798                    plot,
799                    opening,
800                    coverage: 0.0,
801                    dirty: false,
802                });
803            }
804        }
805        // Keep neighbouring viewport queries from fitting, painting and hit testing cached.
806        let keep = profiles.len().saturating_add(4);
807        let excess = cache.len().saturating_sub(keep);
808        cache.drain(..excess);
809        profiles
810    }
811}
812
813/// The least of 1, 2 and 5 times a power of ten that reaches a positive `value`.
814fn nice_ceil(value: f64) -> Option<f64> {
815    let power = 10_f64.powi(value.log10().floor() as i32);
816    [1.0, 2.0, 5.0, 10.0]
817        .into_iter()
818        .map(|step| step * power)
819        .find(|step| *step >= value * (1.0 - 1e-9))
820}
821
822/// Ticks per row of an executed profile over the traded levels `first..=last`: the row height in
823/// whole ticks, at least one. With equal rows, the levels over the row count, rounded up, when
824/// rows of that many ticks, counted from its multiples as a footprint groups them, stay within
825/// the row count; else the levels over one row fewer, which always do. Asked for one row, levels
826/// that straddle a multiple keep two.
827fn ticks_per_row(config: &VolumeProfileConfig, tick: f64, first: i64, last: i64) -> Option<i64> {
828    if let Some(height) = config.row_size() {
829        let ticks = (height / tick).round();
830        return (ticks < i64::MAX as f64).then(|| (ticks as i64).max(1));
831    }
832    let (first, last, rows) = (i128::from(first), i128::from(last), config.rows() as i128);
833    let count = |ticks: i128| last.div_euclid(ticks) - first.div_euclid(ticks) + 1;
834    let span = last - first + 1;
835    let even = (span + rows - 1) / rows;
836    let ticks = if rows == 1 || count(even) <= rows {
837        even
838    } else {
839        (span + rows - 2) / (rows - 1)
840    };
841    i64::try_from(ticks).ok()
842}
843
844/// `bar` with its levels summed into rows of `ticks` levels, level `n` into row
845/// `n.div_euclid(ticks)`, as a footprint with that many ticks per row groups them. Totals and the
846/// delta path are unchanged.
847fn grouped(bar: &FootprintBar, ticks: i64) -> FootprintBar {
848    let mut levels = BTreeMap::new();
849    for (&level, row) in &bar.levels {
850        let key = level.div_euclid(ticks);
851        let target = levels.entry(key).or_insert_with(|| FootprintLevel {
852            level: key,
853            ..Default::default()
854        });
855        target.bid += row.bid;
856        target.ask += row.ask;
857        target.unknown += row.unknown;
858    }
859    FootprintBar {
860        time: bar.time,
861        levels,
862        bid: bar.bid,
863        ask: bar.ask,
864        unknown: bar.unknown,
865        min_delta: bar.min_delta,
866        max_delta: bar.max_delta,
867    }
868}
869
870macro_rules! profile_study {
871    ($ty:ident, $kind:ident, $name:literal, $doc:literal) => {
872        #[doc = $doc]
873        #[derive(Clone)]
874        pub struct $ty {
875            profile: ProfileStudy,
876        }
877        impl $ty {
878            /// Create a profile with this many equal price rows, at least one.
879            /// Counts above 1,000 produce no profile; settings accept 1 to 1,000.
880            pub fn new(row_count: usize) -> Self {
881                Self {
882                    profile: ProfileStudy::new(Kind::$kind, row_count),
883                }
884            }
885
886            /// What each profile in the `visible` chart bars was built from, with the share of
887            /// its bars that the finer source interval covers.
888            pub fn profile_inputs(&self, visible: Range<usize>) -> Vec<ProfileInput> {
889                self.profile.profile_inputs(visible)
890            }
891        }
892        impl Default for $ty {
893            fn default() -> Self {
894                Self::new(24)
895            }
896        }
897        impl Indicator for $ty {
898            fn name(&self) -> &str {
899                $name
900            }
901            fn desc(&self) -> &str {
902                $doc
903            }
904            fn inputs(&self) -> Vec<StudyInput> {
905                self.profile
906                    .inputs()
907                    .into_iter()
908                    .zip(Self::default().profile.inputs())
909                    .map(|(input, default)| input.with_default(default.value))
910                    .collect()
911            }
912            fn set_input(
913                &mut self,
914                id: &str,
915                value: InputValue,
916            ) -> Result<(), crate::studies::StudyError> {
917                self.profile.set_input(id, value)
918            }
919            fn calculate(&mut self, data: &[Bar]) {
920                self.profile.calculate(data);
921            }
922            fn update(&mut self, data: &[Bar], from: usize) {
923                self.profile.update(data, from);
924            }
925            fn data_requests(&self) -> Vec<StudyDataRequest> {
926                self.profile.data_requests()
927            }
928            fn data_start(&self, _: &StudyDataRequest) -> Option<DateTime<Utc>> {
929                self.profile.data_start()
930            }
931            fn update_data(&mut self, data: &[Bar], from: usize, sources: &[StudyData<'_>]) {
932                self.profile.update_data(data, from, sources);
933            }
934            fn order_flow_inputs(&self) -> &[OrderFlowInput] {
935                std::slice::from_ref(&self.profile.flow)
936            }
937            fn needs_order_flow(&self) -> bool {
938                false
939            }
940            fn volume_profiles(&self, visible: Range<usize>) -> Vec<StudyProfile> {
941                self.profile.profiles(visible)
942            }
943            fn set_instrument(&mut self, instrument: Option<&Instrument>) -> bool {
944                self.profile.resolutions =
945                    instrument.map_or_else(Vec::new, |i| i.profile_resolutions.clone());
946                self.profile.tick_size = instrument
947                    .and_then(|i| i.tick_size)
948                    .filter(|t| t.is_finite() && *t > 0.0);
949                self.profile.days.set(instrument)
950            }
951            fn set_visible_range(&mut self, range: Range<usize>) -> bool {
952                self.profile.view = range;
953                false
954            }
955            fn set_timeframe(&mut self, timeframe: Timeframe) {
956                self.profile.interval = timeframe;
957                self.profile.clock = self.profile.clock.for_timeframe(timeframe);
958            }
959            fn set_bar_clock(&mut self, clock: &BarClock) -> bool {
960                let changed = self.profile.clock != *clock;
961                self.profile.clock = clock.clone();
962                changed
963            }
964            fn values(&self) -> &IndicatorValues {
965                &self.profile.values
966            }
967            fn colors(&self) -> Vec<Color32> {
968                self.profile.colors.clone()
969            }
970            fn set_colors(&mut self, colors: Vec<Color32>) {
971                if colors.len() == 3 {
972                    self.profile.colors[3] = colors[1];
973                    self.profile.colors[4] = colors[2];
974                }
975                for (stored, color) in self.profile.colors.iter_mut().zip(colors) {
976                    *stored = color;
977                }
978                self.profile.cache.get_mut().clear();
979            }
980            fn line_names(&self) -> Vec<String> {
981                Vec::new()
982            }
983            fn is_visible(&self) -> bool {
984                self.profile.visible
985            }
986            fn set_visible(&mut self, visible: bool) {
987                self.profile.visible = visible;
988            }
989            fn clone_box(&self) -> Box<dyn Indicator> {
990                Box::new(self.clone())
991            }
992        }
993    };
994}
995profile_study!(
996    VolProfileVisible,
997    Visible,
998    "Vol Profile VR",
999    "Volume distributed over the bars currently visible in the chart."
1000);
1001profile_study!(
1002    VolProfileFixed,
1003    Fixed,
1004    "Vol Profile FR",
1005    "Volume between inclusive saved start and end times; all loaded bars until times are chosen."
1006);
1007profile_study!(
1008    VolProfileSession,
1009    Session,
1010    "Vol Profile Session",
1011    "One volume profile per exchange trading day, including the whole session of visible bars."
1012);
1013profile_study!(
1014    VolProfilePeriodic,
1015    Periodic,
1016    "Vol Profile Periodic",
1017    "One volume profile per chosen exchange calendar period, weekly by default."
1018);
1019profile_study!(
1020    VolProfileAnchored,
1021    Anchored,
1022    "Vol Profile Anchored",
1023    "Volume from a saved start time to the latest bar; all loaded bars until an anchor is chosen."
1024);
1025profile_study!(
1026    VolProfileAutoAnchored,
1027    Auto,
1028    "Vol Profile Auto Anchored",
1029    "Volume from the latest calendar anchor or an extreme in the chosen lookback."
1030);
1031
1032impl VolProfileFixed {
1033    /// Include bars from `start` through `end`. Reversed times select no bars.
1034    pub fn with_range(
1035        mut self,
1036        start: chrono::DateTime<chrono::Utc>,
1037        end: chrono::DateTime<chrono::Utc>,
1038    ) -> Self {
1039        self.profile.start_time = start.timestamp_millis() as f64;
1040        self.profile.end_time = end.timestamp_millis() as f64;
1041        self.profile.data.clear();
1042        self.profile.values.clear();
1043        self.profile.cache.get_mut().clear();
1044        self
1045    }
1046}
1047impl VolProfileAnchored {
1048    /// Include bars at or after `start`, retaining the time when history is prepended.
1049    pub fn with_anchor(mut self, start: chrono::DateTime<chrono::Utc>) -> Self {
1050        self.profile.start_time = start.timestamp_millis() as f64;
1051        self.profile.data.clear();
1052        self.profile.values.clear();
1053        self.profile.cache.get_mut().clear();
1054        self
1055    }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061
1062    /// Profiles sum the executions held for their bars, in rows of whole ticks within the row
1063    /// count or of the row height, as a footprint with that many ticks per row sums them. One
1064    /// whose executions start inside it is partial; one without them, or whose bars they cannot
1065    /// be assigned to exactly, keeps the estimate.
1066    #[test]
1067    fn profiles_read_the_executions_of_their_bars() {
1068        use crate::data::{Footprint, ProfileSource};
1069        use crate::model::{
1070            BarClock,
1071            OrderSide::{Buy, Sell},
1072            Tick,
1073        };
1074        let time = |at: &str| at.parse::<DateTime<Utc>>().unwrap();
1075        let bar = |at: &str| Bar::new(time(at), 100.0, 105.0, 99.0, 102.0, 10.0);
1076        let trade = |at: &str, price, size, side| Tick::new(time(at), price, size, side);
1077        let bars: Vec<Bar> = [
1078            "2026-03-06T23:58:00Z",
1079            "2026-03-06T23:59:00Z",
1080            "2026-03-07T00:00:00Z",
1081            "2026-03-07T00:01:00Z",
1082        ]
1083        .map(bar)
1084        .into();
1085        // Bar 2 trades 2 bought at 100 and 1 sold at 101; bar 3 trades 1 bought at 101 and 3 of
1086        // unknown side at 104.
1087        let trades = vec![
1088            trade("2026-03-07T00:00:10Z", 100.0, 2.0, Some(Buy)),
1089            trade("2026-03-07T00:00:20Z", 101.0, 1.0, Some(Sell)),
1090            trade("2026-03-07T00:01:10Z", 101.0, 1.0, Some(Buy)),
1091            trade("2026-03-07T00:01:20Z", 104.0, 3.0, None),
1092        ];
1093        let negative = vec![
1094            trade("2026-03-07T00:00:10Z", -1.0, 1.0, Some(Buy)),
1095            trade("2026-03-07T00:00:20Z", -3.0, 2.0, Some(Sell)),
1096        ];
1097        // Range bars of these executions end at 00:00:20 and 00:01:30.
1098        let ranged = vec![bar("2026-03-07T00:00:20Z"), bar("2026-03-07T00:01:30Z")];
1099        let range_trades = vec![
1100            trade("2026-03-07T00:00:10Z", 100.0, 1.0, Some(Buy)),
1101            trade("2026-03-07T00:00:40Z", 101.0, 1.0, Some(Buy)),
1102            trade("2026-03-07T00:01:20Z", 102.0, 1.0, Some(Buy)),
1103        ];
1104        let gap = vec![bars[0].clone(), bars[1].clone(), bars[3].clone()];
1105        let mut shared = bars.clone();
1106        shared.insert(3, bars[2].clone());
1107        let fixed = |rows, first: &Bar, last: &Bar| -> Box<dyn Indicator> {
1108            Box::new(VolProfileFixed::new(rows).with_range(first.time, last.time))
1109        };
1110        let mut height = fixed(24, &bars[2], &bars[3]);
1111        height
1112            .set_input("row_size", InputValue::Number(1.6))
1113            .unwrap();
1114        let minute = BarClock::new(Timeframe::Min1);
1115        let (complete, partial, estimated) = (
1116            ProfileSource::Executions { complete: true },
1117            ProfileSource::Executions { complete: false },
1118            ProfileSource::Estimated,
1119        );
1120        // Ticks per row, low, and volume, buys and sells per row.
1121        type Rows = (u32, f64, Vec<f64>, Vec<f64>, Vec<f64>);
1122        let per_tick: Rows = (
1123            1,
1124            99.5,
1125            vec![2.0, 2.0, 0.0, 0.0, 3.0],
1126            vec![2.0, 1.0, 0.0, 0.0, 0.0],
1127            vec![0.0, 1.0, 0.0, 0.0, 0.0],
1128        );
1129        type Expected = Vec<(Range<usize>, ProfileSource, Option<Rows>)>;
1130        type Case = (
1131            &'static str,
1132            Box<dyn Indicator>,
1133            Vec<Bar>,
1134            Vec<Tick>,
1135            BarClock,
1136            Range<usize>,
1137            Expected,
1138        );
1139        let rows: Vec<Case> = vec![
1140            // Levels 100..=104 take 5 rows of 24.
1141            (
1142                "single ticks within the row count",
1143                fixed(24, &bars[2], &bars[3]),
1144                bars.clone(),
1145                trades.clone(),
1146                minute.clone(),
1147                0..4,
1148                vec![(2..4, complete, Some(per_tick.clone()))],
1149            ),
1150            // ceil(5 / 2) = 3 ticks: rows 33 (99..=101) and 34 (102..=104).
1151            (
1152                "the traded ticks over the row count",
1153                fixed(2, &bars[2], &bars[3]),
1154                bars.clone(),
1155                trades.clone(),
1156                minute.clone(),
1157                0..4,
1158                vec![(
1159                    2..4,
1160                    complete,
1161                    Some((3, 98.5, vec![4.0, 3.0], vec![3.0, 0.0], vec![1.0, 0.0])),
1162                )],
1163            ),
1164            // Levels 101..=104: 2 ticks give rows 50, 51, 52, too many; 4 ticks give 25 and 26.
1165            (
1166                "levels across a multiple take wider rows",
1167                fixed(2, &bars[3], &bars[3]),
1168                bars.clone(),
1169                trades.clone(),
1170                minute.clone(),
1171                0..4,
1172                vec![(
1173                    3..4,
1174                    complete,
1175                    Some((4, 99.5, vec![1.0, 3.0], vec![1.0, 0.0], vec![0.0, 0.0])),
1176                )],
1177            ),
1178            // round(1.6) = 2 ticks: rows 50, 51, 52.
1179            (
1180                "the row height in whole ticks",
1181                height,
1182                bars.clone(),
1183                trades.clone(),
1184                minute.clone(),
1185                0..4,
1186                vec![(
1187                    2..4,
1188                    complete,
1189                    Some((
1190                        2,
1191                        99.5,
1192                        vec![4.0, 0.0, 3.0],
1193                        vec![3.0, 0.0, 0.0],
1194                        vec![1.0, 0.0, 0.0],
1195                    )),
1196                )],
1197            ),
1198            // Held from 00:00, after the profile's first bar at 23:59.
1199            (
1200                "executions from inside the profile",
1201                fixed(24, &bars[1], &bars[3]),
1202                bars.clone(),
1203                trades.clone(),
1204                minute.clone(),
1205                0..4,
1206                vec![(1..4, partial, Some(per_tick.clone()))],
1207            ),
1208            (
1209                "a session before the executions",
1210                Box::new(VolProfileSession::new(24)),
1211                bars.clone(),
1212                trades.clone(),
1213                minute.clone(),
1214                0..4,
1215                vec![
1216                    (0..2, estimated, None),
1217                    (2..4, complete, Some(per_tick.clone())),
1218                ],
1219            ),
1220            // Levels -3 and -1 in 2-tick rows: rows -2 (-4..=-3) and -1 (-2..=-1).
1221            (
1222                "negative prices group downwards",
1223                fixed(2, &bars[2], &bars[3]),
1224                bars.clone(),
1225                negative,
1226                minute.clone(),
1227                0..4,
1228                vec![(
1229                    2..4,
1230                    complete,
1231                    Some((2, -4.5, vec![2.0, 1.0], vec![0.0, 1.0], vec![2.0, 0.0])),
1232                )],
1233            ),
1234            (
1235                "executions of another calendar",
1236                fixed(24, &bars[2], &bars[3]),
1237                bars.clone(),
1238                trades.clone(),
1239                BarClock::new(Timeframe::Min5),
1240                0..4,
1241                vec![(2..4, estimated, None)],
1242            ),
1243            (
1244                "executions of a bar the chart lacks",
1245                fixed(24, &bars[1], &bars[3]),
1246                gap,
1247                trades.clone(),
1248                minute.clone(),
1249                0..3,
1250                vec![(1..3, estimated, None)],
1251            ),
1252            (
1253                "a bar before sharing the first bar's slot",
1254                Box::new(VolProfileVisible::new(24)),
1255                shared.clone(),
1256                trades.clone(),
1257                minute.clone(),
1258                3..5,
1259                vec![(3..5, estimated, None)],
1260            ),
1261            (
1262                "a bar after sharing the last bar's slot",
1263                Box::new(VolProfileVisible::new(24)),
1264                shared,
1265                trades.clone(),
1266                minute.clone(),
1267                2..3,
1268                vec![(2..3, estimated, None)],
1269            ),
1270            (
1271                "a bar stamped inside its slot",
1272                fixed(24, &ranged[1], &ranged[1]),
1273                ranged,
1274                range_trades,
1275                minute.clone(),
1276                0..2,
1277                vec![(1..2, estimated, None)],
1278            ),
1279            (
1280                "unordered bar opens keep the estimate",
1281                Box::new(VolProfileVisible::new(24)),
1282                vec![
1283                    bars[3].clone(),
1284                    bar("2026-03-07T00:02:00Z"),
1285                    bars[2].clone(),
1286                ],
1287                trades.clone(),
1288                minute.clone(),
1289                1..3,
1290                vec![(1..3, estimated, None)],
1291            ),
1292        ];
1293        for (label, mut study, bars, trades, clock, view, expected) in rows {
1294            let flow = study.order_flow_inputs()[0].clone();
1295            flow.set_tick_size(Some(1.0));
1296            for trade in &trades {
1297                flow.push_trade(&clock, trade);
1298            }
1299            study.calculate(&bars);
1300            let got = study.volume_profiles(view);
1301            assert_eq!(got.len(), expected.len(), "{label}");
1302            for (got, (range, source, rows)) in got.iter().zip(expected) {
1303                assert_eq!(got.bars, range, "{label}");
1304                assert_eq!(got.profile.source(), source, "{label}");
1305                let Some((ticks, low, volume, up, down)) = rows else {
1306                    continue;
1307                };
1308                let profile = &*got.profile;
1309                assert_eq!(
1310                    (profile.low(), profile.row_height()),
1311                    (low, f64::from(ticks)),
1312                    "{label}"
1313                );
1314                assert_eq!(profile.volume(), volume, "{label}");
1315                assert_eq!(profile.up(), up, "{label}");
1316                assert_eq!(profile.down(), down, "{label}");
1317                // The same executions, grouped by a footprint as they arrive.
1318                let mut footprint = Footprint::new(clock.clone(), 1.0, ticks).unwrap();
1319                for trade in &trades {
1320                    footprint.push(trade).unwrap();
1321                }
1322                let span = footprint.profile(bars[range.start].time, bars[range.end - 1].time);
1323                let independent = VolumeProfile::from_executions(
1324                    &span,
1325                    footprint.price_step(),
1326                    footprint.tick_size(),
1327                    source == complete,
1328                );
1329                assert_eq!(Some(profile), independent.as_ref(), "{label}");
1330            }
1331        }
1332        // A later execution in the last bar shows at the next update, as a fresh calculation has.
1333        let mut study = fixed(24, &bars[2], &bars[3]);
1334        let flow = study.order_flow_inputs()[0].clone();
1335        flow.set_tick_size(Some(1.0));
1336        for trade in &trades {
1337            flow.push_trade(&minute, trade);
1338        }
1339        study.calculate(&bars);
1340        assert_eq!(
1341            study.volume_profiles(0..4)[0].profile.volume(),
1342            [2.0, 2.0, 0.0, 0.0, 3.0]
1343        );
1344        flow.push_trade(&minute, &trade("2026-03-07T00:01:30Z", 104.0, 2.0, None));
1345        study.update(&bars, 3);
1346        let got = study.volume_profiles(0..4);
1347        assert_eq!(got[0].profile.volume(), [2.0, 2.0, 0.0, 0.0, 5.0]);
1348        let mut fresh = study.clone_box();
1349        fresh.calculate(&bars);
1350        assert_eq!(
1351            format!("{got:?}"),
1352            format!("{:?}", fresh.volume_profiles(0..4))
1353        );
1354    }
1355
1356    /// Zoom changes the shared tick grid without reusing cached rows or adding volume.
1357    #[test]
1358    fn adaptive_session_rows_follow_the_sessions_in_view() {
1359        let at = |time: &str| time.parse::<DateTime<Utc>>().unwrap();
1360        let bars = [
1361            Bar::new(
1362                at("2026-03-02T09:00:00Z"),
1363                100.0,
1364                110.0,
1365                100.0,
1366                110.0,
1367                100.0,
1368            ),
1369            Bar::new(at("2026-03-03T09:00:00Z"), 100.0, 101.0, 100.0, 101.0, 40.0),
1370            Bar::new(at("2026-03-03T10:00:00Z"), 101.0, 102.0, 101.0, 102.0, 40.0),
1371        ];
1372        let mut study = VolProfileSession::new(4);
1373        study
1374            .set_input("adaptive_rows", InputValue::Toggle(true))
1375            .unwrap();
1376        study.set_instrument(Some(&Instrument::new("SYNTH").with_tick_size(0.25)));
1377        study.calculate(&bars);
1378        for (view, height, expected) in [
1379            (
1380                0..3,
1381                2.5,
1382                vec![vec![25.0, 25.0, 25.0, 25.0, 0.0], vec![80.0]],
1383            ),
1384            (1..3, 0.5, vec![vec![20.0, 20.0, 20.0, 20.0, 0.0]]),
1385            (
1386                0..3,
1387                2.5,
1388                vec![vec![25.0, 25.0, 25.0, 25.0, 0.0], vec![80.0]],
1389            ),
1390        ] {
1391            let profiles = study.volume_profiles(view);
1392            assert_eq!(profiles.len(), expected.len());
1393            for (plot, volume) in profiles.iter().zip(expected) {
1394                assert_eq!(plot.profile.row_height(), height);
1395                assert_eq!(plot.profile.volume(), volume);
1396            }
1397        }
1398        let mut live = bars.to_vec();
1399        live.push(Bar::new(
1400            at("2026-03-03T11:00:00Z"),
1401            101.0,
1402            102.0,
1403            100.0,
1404            102.0,
1405            20.0,
1406        ));
1407        study.update(&live, 3);
1408        let profiles = study.volume_profiles(1..4);
1409        assert_eq!(profiles[0].profile.row_height(), 0.5);
1410        assert_eq!(profiles[0].profile.volume(), [25.0, 25.0, 25.0, 25.0, 0.0]);
1411        let mut fresh = study.clone_box();
1412        fresh.calculate(&live);
1413        assert_eq!(
1414            format!("{profiles:?}"),
1415            format!("{:?}", fresh.volume_profiles(1..4))
1416        );
1417
1418        study.set_input("row_count", InputValue::Length(3)).unwrap();
1419        for (instrument, height, volume) in [
1420            (
1421                Some(Instrument::new("SYNTH").with_tick_size(0.25)),
1422                1.25,
1423                vec![50.0, 30.0],
1424            ),
1425            (None, 1.0, vec![40.0, 40.0, 0.0]),
1426        ] {
1427            study.set_instrument(instrument.as_ref());
1428            study.calculate(&bars);
1429            let profiles = study.volume_profiles(1..3);
1430            assert_eq!(profiles[0].profile.row_height(), height);
1431            assert_eq!(profiles[0].profile.volume(), volume);
1432        }
1433        let mut flat = VolProfileSession::new(4);
1434        flat.set_input("adaptive_rows", InputValue::Toggle(true))
1435            .unwrap();
1436        flat.calculate(&[Bar::new(bars[0].time, 105.0, 105.0, 105.0, 105.0, 10.0)]);
1437        let profiles = flat.volume_profiles(0..1);
1438        assert_eq!(profiles.len(), 1);
1439        assert_eq!(profiles[0].config.row_size(), None);
1440        assert_eq!(profiles[0].profile.volume(), [10.0]);
1441    }
1442
1443    /// Suggestions serve an inclusive profile length; explicit choices and the opt-out win. A
1444    /// profile emptied to wait for its recompute drops a held interval the chart has reached.
1445    #[test]
1446    fn feed_suggested_interval_follows_the_profile_length() {
1447        let start: DateTime<Utc> = "2026-03-02T09:00:00Z".parse().unwrap();
1448        let bars: Vec<_> = (0..4)
1449            .map(|hour| {
1450                Bar::new(
1451                    start + TimeDelta::hours(hour),
1452                    100.0,
1453                    101.0,
1454                    99.0,
1455                    100.0,
1456                    10.0,
1457                )
1458            })
1459            .collect();
1460        let instrument = Instrument::new("SYNTH").with_profile_resolutions([
1461            ProfileResolution::new(
1462                Timeframe::Volume(std::num::NonZeroU64::new(1).unwrap()),
1463                None,
1464            ),
1465            ProfileResolution::new(Timeframe::Min1, Some(TimeDelta::hours(2))),
1466            ProfileResolution::new(Timeframe::Min5, Some(TimeDelta::hours(4))),
1467            ProfileResolution::new(Timeframe::Min15, None),
1468            ProfileResolution::new(Timeframe::Hour1, None),
1469            ProfileResolution::new(
1470                Timeframe::Ticks(std::num::NonZeroU64::new(1).unwrap()),
1471                None,
1472            ),
1473        ]);
1474        for case in 0..11 {
1475            let mut study: Box<dyn Indicator> = match case {
1476                1 => Box::new(VolProfileFixed::new(4).with_range(bars[0].time, bars[1].time)),
1477                2..=4 => Box::new(VolProfileVisible::new(4)),
1478                _ => Box::new(VolProfileFixed::new(4)),
1479            };
1480            study.set_timeframe(if case == 8 {
1481                Timeframe::Min5
1482            } else {
1483                Timeframe::Hour1
1484            });
1485            if case == 5 {
1486                study
1487                    .set_input(
1488                        "source_interval",
1489                        InputValue::Timeframe(Some(Timeframe::Min15)),
1490                    )
1491                    .unwrap();
1492            }
1493            if case == 6 {
1494                study
1495                    .set_input("feed_source", InputValue::Toggle(false))
1496                    .unwrap();
1497            }
1498            study.set_instrument((case != 7).then_some(&instrument));
1499            if case != 9 {
1500                study.calculate(&bars);
1501            }
1502            study.set_visible_range(match case {
1503                2 => 0..1,
1504                3 => 0..9,
1505                4 => 4..9,
1506                _ => 0..4,
1507            });
1508            if case == 10 {
1509                // Emptied to wait for a recompute, then the chart moves to the held interval.
1510                study.update(&[], 0);
1511                study.set_timeframe(Timeframe::Min5);
1512            }
1513            let expected = match case {
1514                0 | 3 => Some(Timeframe::Min5),
1515                1 | 2 => Some(Timeframe::Min1),
1516                5 => Some(Timeframe::Min15),
1517                _ => None,
1518            };
1519            assert_eq!(
1520                study.data_requests().first().and_then(|r| r.timeframe),
1521                expected,
1522                "case {case}"
1523            );
1524        }
1525    }
1526
1527    /// Range selection uses bar opens and inclusive saved times; calendar profiles retain
1528    /// whole exchange periods while a visible profile includes only the requested bars.
1529    #[test]
1530    fn profiles_use_only_their_selected_bars() {
1531        let times = [
1532            "2026-03-06T22:00:00Z",
1533            "2026-03-06T23:00:00Z",
1534            "2026-03-07T00:00:00Z",
1535            "2026-03-09T01:00:00Z",
1536        ];
1537        let bars: Vec<_> = times
1538            .iter()
1539            .enumerate()
1540            .map(|(i, t)| {
1541                Bar::new(
1542                    t.parse().unwrap(),
1543                    1.0,
1544                    2.0,
1545                    0.0,
1546                    if i == 2 { 0.5 } else { 1.5 },
1547                    (i + 1) as f64 * 10.0,
1548                )
1549            })
1550            .collect();
1551        type Case = (Box<dyn Indicator>, Vec<(Range<usize>, f64)>);
1552        let cases: Vec<Case> = vec![
1553            (Box::new(VolProfileVisible::new(2)), vec![(1..4, 90.0)]),
1554            (
1555                Box::new(VolProfileFixed::new(2).with_range(bars[1].time, bars[2].time)),
1556                vec![(1..3, 50.0)],
1557            ),
1558            (
1559                Box::new(VolProfileAnchored::new(2).with_anchor(bars[2].time)),
1560                vec![(2..4, 70.0)],
1561            ),
1562            (
1563                Box::new(VolProfileSession::new(2)),
1564                vec![(0..2, 30.0), (2..3, 30.0), (3..4, 40.0)],
1565            ),
1566            (
1567                Box::new(VolProfilePeriodic::new(2)),
1568                vec![(0..3, 60.0), (3..4, 40.0)],
1569            ),
1570            (Box::new(VolProfileAutoAnchored::new(2)), vec![(3..4, 40.0)]),
1571        ];
1572        let request = StudyDataRequest {
1573            symbol: String::new(),
1574            timeframe: None,
1575            metric: None,
1576        };
1577        for (mut study, expected) in cases {
1578            let defaults = crate::studies::IndicatorFactory::new()
1579                .create(study.name())
1580                .unwrap()
1581                .inputs();
1582            study
1583                .set_input("value_area", InputValue::Number(90.0))
1584                .unwrap();
1585            for (input, default) in study.inputs().iter().zip(&defaults) {
1586                assert_eq!(
1587                    input.default,
1588                    default.value,
1589                    "{}: {}",
1590                    study.name(),
1591                    input.id
1592                );
1593            }
1594            // Saved UTC times are displayed and accepted back without changing the range.
1595            let times: Vec<_> = study
1596                .inputs()
1597                .into_iter()
1598                .filter_map(|input| match input.value {
1599                    InputValue::Text(text) => Some((input.id, text)),
1600                    _ => None,
1601                })
1602                .collect();
1603            let expected_times: &[(&str, &str)] = match study.name() {
1604                "Vol Profile FR" => &[
1605                    ("start_time", "2026-03-06 23:00:00"),
1606                    ("end_time", "2026-03-07 00:00:00"),
1607                ],
1608                "Vol Profile Anchored" => &[("start_time", "2026-03-07 00:00:00")],
1609                _ => &[],
1610            };
1611            assert_eq!(
1612                times
1613                    .iter()
1614                    .map(|(id, text)| (id.as_str(), text.as_str()))
1615                    .collect::<Vec<_>>(),
1616                expected_times,
1617                "{}",
1618                study.name()
1619            );
1620            for (id, text) in times {
1621                study.set_input(&id, InputValue::Text(text)).unwrap();
1622            }
1623            study
1624                .set_input("value_area", InputValue::Number(70.0))
1625                .unwrap();
1626            study.calculate(&bars);
1627            let got = study.volume_profiles(1..4);
1628            assert_eq!(got.len(), expected.len(), "{}", study.name());
1629            let first = expected[0].0.start;
1630            for (got, (range, volume)) in got.iter().zip(expected) {
1631                assert_eq!(got.bars, range, "{}", study.name());
1632                assert_eq!(got.profile.volume(), [volume / 2.0, volume / 2.0]);
1633                let down = if range.contains(&2) { 15.0 } else { 0.0 };
1634                assert_eq!(got.profile.down(), [down, down]);
1635                assert_eq!(got.profile.up(), [volume / 2.0 - down, volume / 2.0 - down]);
1636            }
1637            let cached = study.volume_profiles(1..4);
1638            assert!(Arc::ptr_eq(&got[0].profile, &cached[0].profile));
1639            // Finer bars are needed from the first bar of the first profile in view, and with
1640            // no profile in view from the newest bar on.
1641            for (view, at) in [(1..4, first), (4..4, 3)] {
1642                study.set_visible_range(view);
1643                assert_eq!(
1644                    study.data_start(&request),
1645                    Some(bars[at].time),
1646                    "{}",
1647                    study.name()
1648                );
1649            }
1650            let mut live = bars.clone();
1651            for step in 0..3 {
1652                let from = match step {
1653                    0 => {
1654                        live[3].close = 0.5;
1655                        live[3].volume += 5.0;
1656                        3
1657                    }
1658                    1 => {
1659                        live.push(Bar::new(
1660                            live[3].time + chrono::Duration::hours(1),
1661                            1.0,
1662                            3.0,
1663                            -1.0,
1664                            1.5,
1665                            8.0,
1666                        ));
1667                        3
1668                    }
1669                    _ => {
1670                        live[0].volume += 9.0;
1671                        0
1672                    }
1673                };
1674                study.update(&live, from);
1675                let mut fresh = study.clone_box();
1676                fresh.calculate(&live);
1677                assert_eq!(
1678                    format!("{:?}", study.volume_profiles(1..live.len())),
1679                    format!("{:?}", fresh.volume_profiles(1..live.len())),
1680                    "{} step {step}",
1681                    study.name()
1682                );
1683            }
1684        }
1685        // Fractions survive the editor, junk changes nothing, and empty fields select all bars.
1686        let mut fixed = VolProfileFixed::new(2).with_range(
1687            bars[1].time + chrono::Duration::milliseconds(500),
1688            bars[2].time,
1689        );
1690        let start = fixed
1691            .inputs()
1692            .into_iter()
1693            .find(|input| input.id == "start_time")
1694            .unwrap();
1695        assert_eq!(
1696            start.value,
1697            InputValue::Text("2026-03-06 23:00:00.500".into())
1698        );
1699        fixed.set_input("start_time", start.value).unwrap();
1700        fixed.calculate(&bars);
1701        assert_eq!(fixed.volume_profiles(0..4)[0].bars, 2..3);
1702        assert!(
1703            fixed
1704                .set_input("start_time", InputValue::Text("23:00".into()))
1705                .is_err()
1706        );
1707        assert_eq!(fixed.volume_profiles(0..4)[0].bars, 2..3);
1708        fixed
1709            .set_input("start_time", InputValue::Text("2026-03-06 23:00".into()))
1710            .unwrap();
1711        fixed.calculate(&bars);
1712        assert_eq!(fixed.volume_profiles(0..4)[0].bars, 1..3);
1713        fixed
1714            .set_input("start_time", InputValue::Text(String::new()))
1715            .unwrap();
1716        fixed
1717            .set_input("end_time", InputValue::Text(String::new()))
1718            .unwrap();
1719        fixed.calculate(&bars);
1720        assert_eq!(fixed.volume_profiles(0..4)[0].bars, 0..4);
1721        let mut visible = VolProfileVisible::new(2);
1722        visible.calculate(&bars);
1723        assert_eq!(
1724            visible.volume_profiles(1..3)[0].profile.volume(),
1725            [25.0, 25.0]
1726        );
1727        assert_eq!(
1728            visible.volume_profiles(0..1)[0].profile.volume(),
1729            [5.0, 5.0]
1730        );
1731        assert!(visible.volume_profiles(4..5).is_empty());
1732        let mut tick = bars.clone();
1733        tick[2].close = 1.5;
1734        tick[2].volume = 50.0;
1735        visible.update(&tick, 2);
1736        let got = visible.volume_profiles(1..3);
1737        assert_eq!(got[0].profile.volume(), [35.0, 35.0]);
1738        assert_eq!(got[0].profile.down(), [0.0, 0.0]);
1739        assert!(visible.values().columns().is_empty());
1740
1741        let mut session = VolProfileSession::new(2);
1742        session
1743            .set_instrument(Some(&Instrument::new("SYNTH").with_time_zone(
1744                crate::model::TimezoneId::fixed_offset(-5 * 3600).unwrap(),
1745            )));
1746        session.calculate(&bars);
1747        let got = session.volume_profiles(1..3);
1748        assert_eq!(got.len(), 1);
1749        assert_eq!(got[0].bars, 0..3);
1750        assert_eq!(got[0].profile.volume(), [30.0, 30.0]);
1751    }
1752}
1753
1754#[cfg(all(test, feature = "widget"))]
1755mod rendering_tests {
1756    use super::*;
1757    use crate::chart::rendering::{ChartMapping, IndicatorRenderer, RenderContext};
1758    use crate::scales::{PriceMap, PriceRange, PriceTransform};
1759    use crate::{model::Bar, studies::Indicator};
1760
1761    /// Painted rows determine the profile's selection and independent price bounds.
1762    #[test]
1763    fn visible_profile_paints_price_rows() {
1764        let bars = [Bar::new(
1765            chrono::DateTime::UNIX_EPOCH,
1766            10.0,
1767            12.0,
1768            10.0,
1769            11.0,
1770            20.0,
1771        )];
1772        let mut study = VolProfileVisible::new(2);
1773        study.calculate(&bars);
1774        let indicators: Vec<Box<dyn Indicator>> = vec![Box::new(study)];
1775        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 200.0));
1776        let price = PriceMap::new(
1777            PriceTransform::default(),
1778            PriceRange::new(9.0, 13.0),
1779            0.0,
1780            200.0,
1781            false,
1782        );
1783        let coords = ChartMapping::new(rect, 40.0, 0, 0, 0.0, price);
1784        let output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
1785            IndicatorRenderer::render(
1786                &RenderContext::new(ui.painter(), rect),
1787                &indicators,
1788                &bars,
1789                &price,
1790                &coords,
1791            );
1792        });
1793        let rows = output
1794            .shapes
1795            .iter()
1796            .filter(|shape| {
1797                matches!(&shape.shape,
1798            egui::Shape::Rect(r) if (r.rect.height() - 48.0).abs() < 0.01)
1799            })
1800            .count();
1801        // The rows are estimated from the bar, as noted at the top left of the
1802        // right-aligned box: x from 280 (400 less 30%) plus 3, y of 12 = 50.
1803        let notes: Vec<_> = output
1804            .shapes
1805            .iter()
1806            .filter_map(|shape| match &shape.shape {
1807                egui::Shape::Text(t) if t.galley.text() == "Estimated from bars" => Some(t.pos),
1808                _ => None,
1809            })
1810            .collect();
1811        output.drop_without_applying_deltas();
1812        assert_eq!(notes, [egui::pos2(283.0, 50.0)]);
1813        assert!(rows >= 2, "profile rows {rows}");
1814        // Equal row volumes put the POC at y=125; its line extends past the row fill.
1815        for (x, y, expected) in [
1816            (395.0, 125.0, true),
1817            (285.0, 125.0, true),
1818            (285.0, 75.0, false),
1819        ] {
1820            let hit = crate::chart::indicators::hit_test_indicator(
1821                egui::pos2(x, y),
1822                indicators[0].as_ref(),
1823                0,
1824                0..1,
1825                &coords,
1826                |value| price.price_to_y(value),
1827            );
1828            assert_eq!(hit.is_some(), expected, "profile selection at ({x}, {y})");
1829        }
1830
1831        // The first bar is outside the painted viewport and must not stretch its scale.
1832        let mut independent =
1833            crate::studies::ConfiguredStudy::new(Box::new(VolProfileVisible::new(2)));
1834        independent.set_scale(crate::studies::StudyScale::Independent);
1835        independent.calculate(&[
1836            Bar {
1837                high: 1000.0,
1838                ..bars[0].clone()
1839            },
1840            bars[0].clone(),
1841            bars[0].clone(),
1842        ]);
1843        let coords = ChartMapping {
1844            base_idx: 10,
1845            ..coords
1846        };
1847        let mapping = crate::chart::rendering::study_mapping(&independent, &coords);
1848        assert!((mapping.price_to_y(12.0) - 200.0 / 12.0).abs() < 0.01);
1849        assert!((mapping.price_to_y(10.0) - 200.0 * 11.0 / 12.0).abs() < 0.01);
1850        assert!(
1851            crate::chart::indicators::hit_test_indicator(
1852                egui::pos2(395.0, mapping.price_to_y(10.5)),
1853                &independent,
1854                0,
1855                0..3,
1856                &mapping,
1857                |value| mapping.price_to_y(value),
1858            )
1859            .is_some()
1860        );
1861    }
1862
1863    /// A box that starts at the pane's left edge, under the chart legend, keeps its source note
1864    /// at the right, short of the value area labels.
1865    #[test]
1866    fn profile_from_the_left_edge_notes_its_source_clear_of_the_legend() {
1867        let bars: Vec<_> = (0..10)
1868            .map(|i| {
1869                let time = chrono::DateTime::UNIX_EPOCH + chrono::Duration::minutes(i);
1870                Bar::new(time, 10.0, 12.0, 10.0, 11.0, 20.0)
1871            })
1872            .collect();
1873        let mut study = VolProfileFixed::new(2);
1874        study.calculate(&bars);
1875        let indicators: Vec<Box<dyn Indicator>> = vec![Box::new(study)];
1876        let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 200.0));
1877        let price = PriceMap::new(
1878            PriceTransform::default(),
1879            PriceRange::new(9.0, 13.0),
1880            0.0,
1881            200.0,
1882            false,
1883        );
1884        let coords = ChartMapping {
1885            base_idx: 9,
1886            ..ChartMapping::new(rect, 40.0, 0, 0, 0.0, price)
1887        };
1888        let output = egui::Context::default().run_ui(egui::RawInput::default(), |ui| {
1889            IndicatorRenderer::render(
1890                &RenderContext::new(ui.painter(), rect),
1891                &indicators,
1892                &bars,
1893                &price,
1894                &coords,
1895            );
1896        });
1897        let note = output.shapes.iter().find_map(|shape| match &shape.shape {
1898            egui::Shape::Text(t) if t.galley.text() == "Estimated from bars" => {
1899                Some((t.pos.x + t.galley.size().x, t.pos.y))
1900            }
1901            _ => None,
1902        });
1903        output.drop_without_applying_deltas();
1904        // Last bar's right edge at 399 (400 less half a 40-point bar, 1, plus half a bar), first
1905        // bar's left edge clamped to 0; the note ends 50 short of 399, at the y of 12 = 50.
1906        let (right, top) = note.expect("source note");
1907        assert!((right - 349.0).abs() < 0.01, "note ends at {right}");
1908        assert_eq!(top, 50.0);
1909    }
1910
1911    /// A finer source interval is requested only when it is shorter than the chart's; its bars
1912    /// replace the estimate for the chart bars they fall in, which keep the estimate where there
1913    /// are none, so the total volume stays and the coverage is the share of bars with finer
1914    /// bars. A reply for another interval is dropped.
1915    #[test]
1916    fn finer_bars_replace_the_estimate_where_they_exist() {
1917        use crate::model::BarClock;
1918        let at = |minutes: i64| {
1919            "2026-03-02T09:00:00Z"
1920                .parse::<chrono::DateTime<chrono::Utc>>()
1921                .unwrap()
1922                + chrono::Duration::minutes(minutes)
1923        };
1924        // Two hourly bars over 100 to 110, and half-hour bars for the first only: 70 at the
1925        // bottom row and 30 at the top row.
1926        let chart = [
1927            Bar::new(at(0), 100.0, 110.0, 100.0, 105.0, 100.0),
1928            Bar::new(at(60), 100.0, 110.0, 100.0, 105.0, 100.0),
1929        ];
1930        let fine = [
1931            Bar::new(at(0), 100.0, 100.9, 100.0, 100.5, 70.0),
1932            Bar::new(at(30), 109.5, 110.0, 109.1, 109.5, 30.0),
1933        ];
1934        let source = |bars: &[Bar], timeframe, hinted| {
1935            let mut study = VolProfileFixed::new(10);
1936            study.set_timeframe(Timeframe::Hour1);
1937            if hinted {
1938                study.set_instrument(Some(
1939                    &Instrument::new("SYNTH")
1940                        .with_profile_resolutions([ProfileResolution::new(timeframe, None)]),
1941                ));
1942            } else {
1943                study
1944                    .set_input("source_interval", InputValue::Timeframe(Some(timeframe)))
1945                    .unwrap();
1946            }
1947            study.calculate(&chart);
1948            study.update_data(
1949                &chart,
1950                0,
1951                &[StudyData {
1952                    request: StudyDataRequest {
1953                        symbol: String::new(),
1954                        timeframe: Some(timeframe),
1955                        metric: None,
1956                    },
1957                    bars,
1958                    clock: BarClock::new(timeframe),
1959                    instrument: None,
1960                    timeframe,
1961                }],
1962            );
1963            study
1964        };
1965        let expected = [80.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 40.0];
1966        for (interval, hinted) in [
1967            (Timeframe::Hour1, false),
1968            (Timeframe::Hour1, true),
1969            (Timeframe::Custom(std::num::NonZeroU64::MAX), false),
1970        ] {
1971            let mut study = source(&fine, Timeframe::Min30, hinted);
1972            study.set_timeframe(interval);
1973            assert_eq!(study.data_requests()[0].timeframe, Some(Timeframe::Min30));
1974            let volume = study.volume_profiles(0..2)[0].profile.volume().to_vec();
1975            assert!(
1976                volume
1977                    .iter()
1978                    .zip(expected)
1979                    .all(|(v, e)| (v - e).abs() < 1e-9),
1980                "{volume:?}"
1981            );
1982            assert_eq!(
1983                study.profile_inputs(0..2),
1984                [ProfileInput {
1985                    bars: 0..2,
1986                    coverage: 0.5
1987                }]
1988            );
1989        }
1990
1991        // A longer source interval is not requested, and another interval's reply is dropped.
1992        let mut longer = VolProfileFixed::new(10);
1993        longer.set_timeframe(Timeframe::Hour1);
1994        longer
1995            .set_input(
1996                "source_interval",
1997                InputValue::Timeframe(Some(Timeframe::Day1)),
1998            )
1999            .unwrap();
2000        assert!(longer.data_requests().is_empty());
2001        let mut other = source(&fine, Timeframe::Min30, false);
2002        other
2003            .set_input(
2004                "source_interval",
2005                InputValue::Timeframe(Some(Timeframe::Min15)),
2006            )
2007            .unwrap();
2008        other.calculate(&chart);
2009        other.update_data(&chart, 0, &[]);
2010        assert_eq!(other.profile_inputs(0..2)[0].coverage, 0.0);
2011    }
2012}