1use 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#[derive(Clone, Debug, PartialEq)]
42pub struct ProfileInput {
43 pub bars: Range<usize>,
45 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 clock: BarClock,
77 source: Option<Timeframe>,
78 feed_source: bool,
80 adaptive: bool,
81 tick_size: Option<f64>,
82 resolutions: Vec<ProfileResolution>,
83 held: Option<(Option<Timeframe>, Option<DateTime<Utc>>)>,
86 view: Range<usize>,
88 days: ExchangeDays,
89 data: Vec<Bar>,
90 fine: Vec<Bar>,
91 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
136const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.f";
138
139pub(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
148fn 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 fn finer(&self) -> Option<Timeframe> {
337 let shorter = |source: &Timeframe| {
338 !source.is_trade_based() && source.duration_ms() < self.interval.duration_ms()
339 };
340 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 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 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 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 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 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 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 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 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 Some(self.flow.footprints_from(from))
460 }
461
462 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 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 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 if opens.peek() != Some(&time) {
507 return None;
508 }
509 merged.absorb(bar);
510 }
511 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 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 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 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 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 let keep = profiles.len().saturating_add(4);
807 let excess = cache.len().saturating_sub(keep);
808 cache.drain(..excess);
809 profiles
810 }
811}
812
813fn 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
822fn 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
844fn 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 pub fn new(row_count: usize) -> Self {
881 Self {
882 profile: ProfileStudy::new(Kind::$kind, row_count),
883 }
884 }
885
886 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 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 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 #[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 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 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 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 (
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 (
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 (
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 (
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 (
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 (
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 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 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 #[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 #[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 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 #[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 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 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 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 #[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 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 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 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 #[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 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 #[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 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 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}