1use super::clamp_param;
39use crate::model::{Bar, EventKind, Session, SessionWindow, TimeScaleEvent, Timeframe, TimezoneId};
40use crate::studies::palette;
41use crate::studies::stepper::Stepper;
42use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
43use chrono::{
44 DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeZone, Timelike, Utc,
45};
46use egui::Color32;
47
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum VwapAnchor {
52 #[default]
54 Session,
55 Week,
57 Month,
59 Quarter,
61 Year,
63 Events,
65 Decade,
67 Century,
69 Earnings,
71 Dividends,
73 Splits,
75}
76
77const VWAP_ANCHORS: [VwapAnchor; 11] = [
78 VwapAnchor::Session,
79 VwapAnchor::Week,
80 VwapAnchor::Month,
81 VwapAnchor::Quarter,
82 VwapAnchor::Year,
83 VwapAnchor::Events,
84 VwapAnchor::Decade,
85 VwapAnchor::Century,
86 VwapAnchor::Earnings,
87 VwapAnchor::Dividends,
88 VwapAnchor::Splits,
89];
90
91const MAX_BANDS: usize = 10;
93const MAX_EVENTS: usize = 1000;
95
96const EVENT_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
98
99fn slot(id: &str, prefix: &str) -> Option<usize> {
101 id.strip_prefix(prefix)?.parse().ok()
102}
103
104fn day_start(windows: &[SessionWindow]) -> usize {
107 windows
108 .iter()
109 .filter(|w| w.close <= w.open && w.open > NaiveTime::MIN)
110 .map(|w| (w.open.hour() * 60 + w.open.minute()) as usize)
111 .min()
112 .unwrap_or(0)
113}
114
115fn day_start_windows(minutes: usize) -> Vec<SessionWindow> {
117 NaiveTime::from_hms_opt((minutes / 60) as u32, (minutes % 60) as u32, 0)
118 .filter(|open| *open > NaiveTime::MIN)
119 .map(|open| {
120 vec![SessionWindow::new(
121 Vec::<chrono::Weekday>::new(),
122 open,
123 open,
124 )]
125 })
126 .unwrap_or_default()
127}
128
129impl VwapAnchor {
130 fn period(self, day: NaiveDate) -> (i32, u32) {
132 match self {
133 Self::Session => (day.year(), day.ordinal()),
134 Self::Week => (day.iso_week().year(), day.iso_week().week()),
135 Self::Month => (day.year(), day.month()),
136 Self::Quarter => (day.year(), (day.month() - 1) / 3),
137 Self::Year => (day.year(), 0),
138 Self::Decade => (day.year().div_euclid(10), 0),
139 Self::Century => (day.year().div_euclid(100), 0),
140 Self::Events | Self::Earnings | Self::Dividends | Self::Splits => (0, 0),
141 }
142 }
143}
144
145pub(super) fn trading_day(
148 time: DateTime<Utc>,
149 zone: &TimezoneId,
150 windows: &[SessionWindow],
151) -> NaiveDate {
152 let utc = time.naive_utc();
153 let local = utc
155 .checked_add_offset(zone.offset_from_utc_datetime(&utc).fix())
156 .unwrap_or(utc);
157 let date = local.date();
158 if windows
160 .iter()
161 .any(|w| w.close <= w.open && w.open > NaiveTime::MIN && local.time() >= w.open)
162 {
163 date.succ_opt().unwrap_or(date)
164 } else {
165 date
166 }
167}
168
169#[derive(Clone)]
178pub struct VolumeWeightedAvgPrice {
179 values: IndicatorValues,
180 stepper: Stepper<State>,
181 shared: Option<usize>,
183 daily: bool,
185 timeframe: Option<Timeframe>,
186 colors: Vec<Color32>,
188 visible: bool,
189 anchor: VwapAnchor,
190 time_zone: TimezoneId,
191 windows: Vec<SessionWindow>,
192 instrument_session: Option<(TimezoneId, Vec<SessionWindow>)>,
193 bands: Vec<f64>,
194 percentage_bands: bool,
195 event_anchors: Vec<DateTime<Utc>>,
196 event_zone: TimezoneId,
198 chart_events: Vec<(EventKind, DateTime<Utc>)>,
200}
201
202#[derive(Clone, Default)]
203struct State {
204 period: Option<(i32, u32)>,
205 events: usize,
206 volume: f64,
207 tp_volume: f64,
208 tp2_volume: f64,
209}
210
211impl VolumeWeightedAvgPrice {
212 pub fn new() -> Self {
214 Self {
215 values: IndicatorValues::new(),
216 stepper: Stepper::default(),
217 shared: None,
218 daily: true,
219 timeframe: None,
220 colors: vec![palette::BLUE],
221 visible: true,
222 anchor: VwapAnchor::Session,
223 time_zone: TimezoneId::utc(),
224 windows: Vec::new(),
225 instrument_session: None,
226 bands: Vec::new(),
227 percentage_bands: false,
228 event_anchors: Vec::new(),
229 event_zone: TimezoneId::utc(),
230 chart_events: Vec::new(),
231 }
232 }
233
234 pub fn with_anchor(mut self, anchor: VwapAnchor) -> Self {
236 self.anchor = anchor;
237 self
238 }
239
240 pub fn with_time_zone(mut self, time_zone: TimezoneId) -> Self {
246 self.time_zone = time_zone;
247 self
248 }
249
250 pub fn with_session(mut self, session: Session) -> Self {
254 self.windows = day_start_windows(day_start(&session.windows));
255 self
256 }
257
258 pub fn with_std_dev_bands(mut self, multipliers: impl IntoIterator<Item = f64>) -> Self {
263 self.percentage_bands = false;
264 self.bands = multipliers
265 .into_iter()
266 .take(MAX_BANDS)
267 .map(|k| clamp_param(k, 0.0, f64::MAX))
268 .collect();
269 self.colors.truncate(1);
270 self.fit_colors();
271 self
272 }
273
274 fn fit_colors(&mut self) {
276 let colors = [
277 palette::GREEN,
278 Color32::from_rgb(128, 128, 0),
279 Color32::from_rgb(0, 137, 123),
280 ];
281 while self.colors.len() < self.line_count() {
282 let band = (self.colors.len() - 1) / 2;
283 self.colors.push(colors[band.min(2)]);
284 }
285 }
286
287 pub fn with_percentage_bands(self, percentages: impl IntoIterator<Item = f64>) -> Self {
290 let mut study = self.with_std_dev_bands(percentages);
291 study.percentage_bands = true;
292 study
293 }
294
295 pub fn with_event_anchors(mut self, times: impl IntoIterator<Item = DateTime<Utc>>) -> Self {
300 self.event_anchors = times.into_iter().collect();
301 self.event_anchors.sort_unstable();
302 self.event_anchors.dedup();
303 self.anchor = VwapAnchor::Events;
304 self
305 }
306
307 pub fn with_color(mut self, color: Color32) -> Self {
309 self.colors.fill(color.gamma_multiply(0.5));
310 self.colors[0] = color;
311 self
312 }
313}
314
315impl Default for VolumeWeightedAvgPrice {
316 fn default() -> Self {
317 Self::new()
318 }
319}
320
321impl Indicator for VolumeWeightedAvgPrice {
322 fn source(&self) -> Option<crate::studies::StudySource> {
323 Some(crate::studies::StudySource::Price(
324 crate::model::PriceSource::HLC3,
325 ))
326 }
327
328 fn inputs(&self) -> Vec<crate::studies::StudyInput> {
329 use crate::studies::{InputKind, InputValue, StudyInput};
330 let mut inputs = vec![
331 StudyInput::new(
332 "anchor",
333 "Anchor",
334 InputValue::Choice(self.anchor as usize),
335 InputKind::Choice(
336 VWAP_ANCHORS
337 .iter()
338 .map(|anchor| format!("{anchor:?}"))
339 .collect(),
340 ),
341 )
342 .with_default(InputValue::Choice(0)),
343 StudyInput::new(
344 "time_zone",
345 "Time zone",
346 InputValue::Text(self.time_zone.name().to_owned()),
347 InputKind::Text,
348 )
349 .with_default(InputValue::Text("UTC".to_owned())),
350 StudyInput::new(
351 "day_start",
352 "Trading day start, minutes after midnight",
353 InputValue::Length(day_start(&self.windows)),
354 InputKind::Length(0, 1439),
355 )
356 .with_default(InputValue::Length(0)),
357 StudyInput::new(
358 "percentage_bands",
359 "Percentage bands",
360 InputValue::Toggle(self.percentage_bands),
361 InputKind::Toggle,
362 )
363 .with_default(InputValue::Toggle(false)),
364 StudyInput::new(
365 "band_count",
366 "Bands",
367 InputValue::Length(self.bands.len()),
368 InputKind::Length(0, MAX_BANDS),
369 )
370 .with_default(InputValue::Length(0)),
371 ];
372 inputs.extend(self.bands.iter().enumerate().map(|(i, k)| {
373 StudyInput::new(
374 format!("band_{i}"),
375 format!("Band {} multiplier", i + 1),
376 InputValue::Number(*k),
377 InputKind::Number(0.0, f64::MAX),
378 )
379 .with_default(InputValue::Number((i + 1) as f64))
380 }));
381 inputs.extend([
382 StudyInput::new(
383 "event_time_zone",
384 "Event time zone",
385 InputValue::Text(self.event_zone.name().to_owned()),
386 InputKind::Text,
387 )
388 .with_default(InputValue::Text("UTC".to_owned())),
389 StudyInput::new(
390 "event_count",
391 "Events",
392 InputValue::Length(self.event_anchors.len()),
393 InputKind::Length(0, MAX_EVENTS),
394 )
395 .with_default(InputValue::Length(0)),
396 ]);
397 inputs.extend(self.event_anchors.iter().enumerate().map(|(i, time)| {
398 let local = time.with_timezone(&self.event_zone).naive_local();
399 StudyInput::new(
400 format!("event_{i}"),
401 format!("Event {} (YYYY-MM-DD HH:MM:SS)", i + 1),
402 InputValue::Text(local.format(EVENT_FORMAT).to_string()),
403 InputKind::Text,
404 )
405 .with_default(InputValue::Text(String::new()))
406 }));
407 inputs
408 }
409
410 fn set_input(
418 &mut self,
419 id: &str,
420 value: crate::studies::InputValue,
421 ) -> Result<(), crate::studies::StudyError> {
422 use crate::studies::InputValue;
423 if id == "daily" && matches!(value, InputValue::Toggle(_)) {
425 return Ok(());
426 }
427 if slot(id, "band_").is_some_and(|i| (self.bands.len()..MAX_BANDS).contains(&i))
428 || slot(id, "event_")
429 .is_some_and(|i| (self.event_anchors.len()..MAX_EVENTS).contains(&i))
430 {
431 return Ok(());
432 }
433 let schema = self
434 .inputs()
435 .into_iter()
436 .find(|input| input.id == id)
437 .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
438 if !schema.accepts(&value) {
439 return Err(crate::studies::StudyError::invalid_input(id));
440 }
441 match (id, value) {
442 ("anchor", InputValue::Choice(index)) => self.anchor = VWAP_ANCHORS[index],
443 ("time_zone", InputValue::Text(name)) => {
444 self.time_zone = TimezoneId::named(&name).ok_or_else(|| {
445 crate::studies::StudyError::InvalidInput {
446 id: id.into(),
447 reason: "names no known time zone".into(),
448 }
449 })?;
450 }
451 ("day_start", InputValue::Length(minutes)) => self.windows = day_start_windows(minutes),
452 ("percentage_bands", InputValue::Toggle(value)) => self.percentage_bands = value,
453 ("band_count", InputValue::Length(count)) => {
454 let mut next = self.bands.len();
455 self.bands.resize_with(count, || {
456 next += 1;
457 next as f64
458 });
459 self.fit_colors();
460 }
461 ("event_time_zone", InputValue::Text(name)) => {
462 self.event_zone = TimezoneId::named(&name).ok_or_else(|| {
463 crate::studies::StudyError::InvalidInput {
464 id: id.into(),
465 reason: "names no known time zone".into(),
466 }
467 })?;
468 }
469 ("event_count", InputValue::Length(count)) => {
470 self.event_anchors.resize(count, DateTime::UNIX_EPOCH);
471 }
472 (id, InputValue::Number(value)) if slot(id, "band_").is_some() => {
473 self.bands[slot(id, "band_").unwrap()] = value;
474 }
475 (id, InputValue::Text(text)) if slot(id, "event_").is_some() => {
476 let local = NaiveDateTime::parse_from_str(&text, EVENT_FORMAT)
477 .or_else(|_| NaiveDateTime::parse_from_str(&text, "%Y-%m-%d %H:%M"))
478 .map_err(|_| crate::studies::StudyError::InvalidInput {
479 id: id.into(),
480 reason: "must read YYYY-MM-DD HH:MM:SS".into(),
481 })?;
482 self.event_anchors[slot(id, "event_").unwrap()] = self
483 .event_zone
484 .from_local_datetime(&local)
485 .earliest()
486 .ok_or_else(|| crate::studies::StudyError::InvalidInput {
487 id: id.into(),
488 reason: "names a time that does not exist in the event time zone".into(),
489 })?
490 .with_timezone(&Utc);
491 }
492 _ => return Err(crate::studies::StudyError::rejected(&self.inputs(), id)),
493 }
494 self.calculate(&[]);
495 Ok(())
496 }
497
498 fn name(&self) -> &str {
499 "VWAP"
500 }
501
502 fn desc(&self) -> &str {
503 "Volume Weighted Avg Price - Trading benchmark indicator"
504 }
505
506 fn calculate(&mut self, data: &[Bar]) {
507 self.update(data, 0);
508 }
509
510 fn update(&mut self, data: &[Bar], from: usize) {
511 let from = from.min(self.values.len()).min(data.len());
512 self.shared = self.shared.filter(|&at| at < from).or_else(|| {
514 (from.max(1)..data.len())
515 .find(|&at| data[at - 1].time.date_naive() == data[at].time.date_naive())
516 });
517 let daily = self.timeframe.map_or(self.shared.is_none(), |tf| {
518 tf.duration_ms() >= Timeframe::Day1.duration_ms()
519 });
520 let from = if daily != self.daily { 0 } else { from };
521 self.daily = daily;
522 let (zone, windows) = self.instrument_session.as_ref().map_or(
523 (&self.time_zone, self.windows.as_slice()),
524 |(zone, windows)| (zone, windows.as_slice()),
525 );
526 let chart_kind = match self.anchor {
527 VwapAnchor::Earnings => Some(EventKind::Earnings),
528 VwapAnchor::Dividends => Some(EventKind::Dividend),
529 VwapAnchor::Splits => Some(EventKind::Split),
530 _ => None,
531 };
532 let mut events: Vec<_> = if self.anchor == VwapAnchor::Events {
534 self.event_anchors.clone()
535 } else {
536 self.chart_events
537 .iter()
538 .filter(|(kind, _)| Some(*kind) == chart_kind)
539 .map(|(_, time)| *time)
540 .collect()
541 };
542 events.sort_unstable();
543 events.dedup();
544 self.stepper
545 .run(data, from, &mut self.values, |state, bar| {
546 if ![bar.high, bar.low, bar.close, bar.volume]
548 .iter()
549 .all(|x| x.is_finite())
550 {
551 return IndicatorValue::None;
552 }
553 let day = if daily {
554 bar.time.date_naive()
555 } else {
556 trading_day(bar.time, zone, windows)
557 };
558 let current = Some(self.anchor.period(day));
559 let events = events.partition_point(|time| *time <= bar.time);
560 if current != state.period || events != state.events {
561 state.events = events;
562 state.period = current;
563 (state.volume, state.tp_volume, state.tp2_volume) = (0.0, 0.0, 0.0);
564 }
565
566 let tp = bar.typical_price();
567 state.volume += bar.volume;
568 state.tp_volume += tp * bar.volume;
569 state.tp2_volume += tp * tp * bar.volume;
570
571 let (vwap, sigma) = if state.volume == 0.0 {
572 (tp, 0.0)
573 } else {
574 let vwap = state.tp_volume / state.volume;
575 (
577 vwap,
578 (state.tp2_volume / state.volume - vwap * vwap)
579 .max(0.0)
580 .sqrt(),
581 )
582 };
583 let band_unit = if self.percentage_bands {
584 vwap.abs() / 100.0
585 } else {
586 sigma
587 };
588 if self.bands.is_empty() {
589 IndicatorValue::Single(vwap)
590 } else {
591 IndicatorValue::Multiple(
592 std::iter::once(vwap)
593 .chain(
594 self.bands
595 .iter()
596 .flat_map(|k| [vwap + k * band_unit, vwap - k * band_unit]),
597 )
598 .collect(),
599 )
600 }
601 });
602 }
603
604 fn set_timeframe(&mut self, timeframe: Timeframe) {
605 self.timeframe = Some(timeframe);
606 }
607
608 fn set_instrument(&mut self, instrument: Option<&crate::model::Instrument>) -> bool {
609 let session = instrument.map(|instrument| {
610 (
611 instrument.time_zone.clone().unwrap_or_else(TimezoneId::utc),
612 instrument
613 .session
614 .as_ref()
615 .map_or(&[][..], |s| s.windows.as_slice()),
616 )
617 });
618 if self
619 .instrument_session
620 .as_ref()
621 .map(|(zone, windows)| (zone, windows.as_slice()))
622 == session.as_ref().map(|(zone, windows)| (zone, *windows))
623 {
624 return false;
625 }
626 self.instrument_session = session.map(|(zone, windows)| (zone, windows.to_vec()));
627 true
628 }
629
630 fn set_time_scale_events(&mut self, events: &[TimeScaleEvent]) -> bool {
631 let corporate = |event: &&TimeScaleEvent| {
632 matches!(
633 event.kind,
634 EventKind::Earnings | EventKind::Dividend | EventKind::Split
635 )
636 };
637 let pairs = || {
638 events
639 .iter()
640 .filter(corporate)
641 .map(|event| (event.kind, event.time))
642 };
643 if self.chart_events.iter().copied().eq(pairs()) {
645 return false;
646 }
647 self.chart_events = pairs().collect();
648 matches!(
649 self.anchor,
650 VwapAnchor::Earnings | VwapAnchor::Dividends | VwapAnchor::Splits
651 )
652 }
653
654 fn is_incremental(&self) -> bool {
655 true
656 }
657
658 fn values(&self) -> &IndicatorValues {
659 &self.values
660 }
661
662 fn colors(&self) -> Vec<Color32> {
663 self.colors.clone()
664 }
665
666 fn set_colors(&mut self, colors: Vec<Color32>) {
668 for (line, color) in self.colors.iter_mut().zip(colors) {
669 *line = color;
670 }
671 }
672
673 fn is_overlay(&self) -> bool {
674 true }
676
677 fn is_visible(&self) -> bool {
678 self.visible
679 }
680
681 fn set_visible(&mut self, visible: bool) {
682 self.visible = visible;
683 }
684
685 fn clone_box(&self) -> Box<dyn Indicator> {
686 Box::new(self.clone())
687 }
688
689 fn line_count(&self) -> usize {
690 1 + 2 * self.bands.len()
691 }
692
693 fn line_names(&self) -> Vec<String> {
694 std::iter::once("VWAP".to_string())
695 .chain(self.bands.iter().flat_map(|k| {
696 let unit = if self.percentage_bands { "%" } else { "σ" };
697 [format!("Upper {k}{unit}"), format!("Lower {k}{unit}")]
698 }))
699 .collect()
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::studies::InputValue;
707 use chrono::{NaiveDateTime, NaiveTime, TimeZone, Weekday};
708
709 struct Case {
710 name: &'static str,
711 zone: Option<&'static str>,
713 window: Option<(u32, u32)>,
715 anchor: VwapAnchor,
716 bands: &'static [f64],
717 bars: &'static [(&'static str, [f64; 4])],
719 want: &'static [&'static [f64]],
721 }
722
723 #[test]
730 fn calculate_matches_reference() {
731 let cases = [
732 Case {
733 name: "new() restarts at UTC midnight",
734 zone: None,
735 window: None,
736 anchor: VwapAnchor::Session,
737 bands: &[],
738 bars: &[
739 ("2026-03-02 22:00", [101.0, 99.0, 100.0, 1000.0]),
740 ("2026-03-02 23:00", [103.0, 100.0, 102.0, 2000.0]),
741 ("2026-03-03 00:00", [104.0, 101.0, 103.0, 1500.0]),
742 ("2026-03-03 01:00", [102.0, 100.0, 101.0, 500.0]),
743 ],
744 want: &[
745 &[100.0],
746 &[101.11111111111111],
747 &[102.66666666666667],
748 &[102.25],
749 ],
750 },
751 Case {
752 name: "Sydney session across UTC midnight",
754 zone: Some("Australia/Sydney"),
755 window: Some((10, 16)),
756 anchor: VwapAnchor::Session,
757 bands: &[],
758 bars: &[
759 ("2026-03-02 15:00", [101.0, 99.0, 100.0, 1000.0]),
760 ("2026-03-03 10:00", [103.0, 101.0, 102.0, 2000.0]),
761 ("2026-03-03 11:00", [104.0, 102.0, 103.0, 1500.0]),
762 ("2026-03-03 12:00", [102.0, 100.0, 101.0, 800.0]),
763 ],
764 want: &[
765 &[100.0],
766 &[102.0],
767 &[102.42857142857143],
768 &[102.16279069767442],
769 ],
770 },
771 Case {
772 name: "Chicago session across local midnight",
774 zone: Some("America/Chicago"),
775 window: Some((17, 16)),
776 anchor: VwapAnchor::Session,
777 bands: &[],
778 bars: &[
779 ("2026-03-02 14:00", [101.0, 99.0, 100.0, 1000.0]),
780 ("2026-03-02 15:00", [102.0, 100.0, 101.0, 1200.0]),
781 ("2026-03-02 17:00", [103.0, 101.0, 102.0, 900.0]),
782 ("2026-03-02 18:00", [104.0, 102.0, 103.0, 1100.0]),
783 ("2026-03-03 09:00", [105.0, 103.0, 104.0, 700.0]),
784 ],
785 want: &[
786 &[100.0],
787 &[100.54545454545455],
788 &[102.0],
789 &[102.55],
790 &[102.92592592592592],
791 ],
792 },
793 Case {
794 name: "Chicago week",
796 zone: Some("America/Chicago"),
797 window: Some((17, 16)),
798 anchor: VwapAnchor::Week,
799 bands: &[],
800 bars: &[
801 ("2026-03-05 15:00", [101.0, 99.0, 100.0, 1000.0]),
802 ("2026-03-06 15:00", [102.0, 100.0, 101.0, 1200.0]),
803 ("2026-03-08 17:00", [99.0, 97.0, 98.0, 900.0]),
804 ("2026-03-08 18:00", [100.0, 98.0, 99.0, 600.0]),
805 ("2026-03-09 17:00", [100.0, 98.0, 99.0, 1100.0]),
806 ],
807 want: &[
808 &[100.0],
809 &[100.54545454545455],
810 &[98.0],
811 &[98.4],
812 &[98.65384615384616],
813 ],
814 },
815 Case {
816 name: "New York daily bars at UTC midnight, week",
819 zone: Some("America/New_York"),
820 window: None,
821 anchor: VwapAnchor::Week,
822 bands: &[],
823 bars: &[
824 ("2024-01-03 19:00", [101.0, 99.0, 100.0, 1000.0]),
825 ("2024-01-04 19:00", [102.0, 100.0, 101.0, 1200.0]),
826 ("2024-01-07 19:00", [99.0, 97.0, 98.0, 900.0]),
827 ("2024-01-08 19:00", [100.0, 98.0, 99.0, 1100.0]),
828 ],
829 want: &[&[100.0], &[100.54545454545455], &[98.0], &[98.55]],
830 },
831 Case {
832 name: "New York daily bars with live stamps, week",
835 zone: Some("America/New_York"),
836 window: None,
837 anchor: VwapAnchor::Week,
838 bands: &[],
839 bars: &[
840 ("2024-01-03 19:00", [101.0, 99.0, 100.0, 1000.0]),
841 ("2024-01-04 19:00", [102.0, 100.0, 101.0, 1200.0]),
842 ("2024-01-07 19:00", [99.0, 97.0, 98.0, 900.0]),
843 ("2024-01-09 15:59", [100.0, 98.0, 99.0, 1100.0]),
844 ("2024-01-10 09:30", [101.0, 99.0, 100.0, 800.0]),
845 ],
846 want: &[
847 &[100.0],
848 &[100.54545454545455],
849 &[98.0],
850 &[98.55],
851 &[98.96428571428571],
852 ],
853 },
854 Case {
855 name: "Tokyo daily bars before UTC midnight, month",
858 zone: Some("Asia/Tokyo"),
859 window: None,
860 anchor: VwapAnchor::Month,
861 bands: &[],
862 bars: &[
863 ("2024-01-31 08:00", [101.0, 99.0, 100.0, 1000.0]),
864 ("2024-02-01 08:00", [102.0, 100.0, 101.0, 1200.0]),
865 ("2024-02-02 08:00", [99.0, 97.0, 98.0, 900.0]),
866 ("2024-02-03 08:00", [100.0, 98.0, 99.0, 1100.0]),
867 ],
868 want: &[&[100.0], &[100.54545454545455], &[98.0], &[98.55]],
869 },
870 Case {
871 name: "UTC month",
872 zone: None,
873 window: None,
874 anchor: VwapAnchor::Month,
875 bands: &[],
876 bars: &[
877 ("2026-01-30 12:00", [101.0, 99.0, 100.0, 1000.0]),
878 ("2026-01-31 12:00", [102.0, 100.0, 101.0, 1200.0]),
879 ("2026-02-01 12:00", [99.0, 97.0, 98.0, 900.0]),
880 ("2026-02-02 12:00", [100.0, 98.0, 99.0, 1100.0]),
881 ],
882 want: &[&[100.0], &[100.54545454545455], &[98.0], &[98.55]],
883 },
884 Case {
885 name: "UTC month with a 24-hour session",
887 zone: None,
888 window: Some((0, 0)),
889 anchor: VwapAnchor::Month,
890 bands: &[],
891 bars: &[
892 ("2026-01-30 12:00", [101.0, 99.0, 100.0, 1000.0]),
893 ("2026-01-31 12:00", [102.0, 100.0, 101.0, 1200.0]),
894 ("2026-02-01 12:00", [99.0, 97.0, 98.0, 900.0]),
895 ("2026-02-01 13:00", [100.0, 98.0, 99.0, 600.0]),
896 ("2026-02-02 12:00", [100.0, 98.0, 99.0, 1100.0]),
897 ],
898 want: &[
899 &[100.0],
900 &[100.54545454545455],
901 &[98.0],
902 &[98.4],
903 &[98.65384615384616],
904 ],
905 },
906 Case {
907 name: "UTC decade",
909 zone: None,
910 window: None,
911 anchor: VwapAnchor::Decade,
912 bands: &[],
913 bars: &[
914 ("2028-12-29 00:00", [101.0, 99.0, 100.0, 1000.0]),
915 ("2029-01-02 00:00", [103.0, 101.0, 102.0, 1000.0]),
916 ("2030-01-02 00:00", [111.0, 109.0, 110.0, 1000.0]),
917 ],
918 want: &[&[100.0], &[101.0], &[110.0]],
919 },
920 Case {
921 name: "UTC century",
923 zone: None,
924 window: None,
925 anchor: VwapAnchor::Century,
926 bands: &[],
927 bars: &[
928 ("2089-12-30 00:00", [101.0, 99.0, 100.0, 1000.0]),
929 ("2090-01-03 00:00", [103.0, 101.0, 102.0, 1000.0]),
930 ("2100-01-04 00:00", [111.0, 109.0, 110.0, 1000.0]),
931 ],
932 want: &[&[100.0], &[101.0], &[110.0]],
933 },
934 Case {
935 name: "bands",
937 zone: None,
938 window: None,
939 anchor: VwapAnchor::Session,
940 bands: &[1.0, 2.5],
941 bars: &[
942 ("2026-03-02 10:00", [101.0, 99.0, 100.0, 1000.0]),
943 ("2026-03-02 11:00", [104.0, 100.0, 102.0, 2000.0]),
944 ("2026-03-02 12:00", [103.0, 97.0, 99.0, 1500.0]),
945 ("2026-03-03 10:00", [102.0, 100.0, 101.0, 0.0]),
946 ("2026-03-03 11:00", [104.0, 102.0, 103.0, 500.0]),
947 ],
948 want: &[
949 &[100.0, 100.0, 100.0, 100.0, 100.0],
950 &[
951 101.33333333333333,
952 102.2761423749154,
953 100.39052429175128,
954 103.69035593728849,
955 98.97631072937817,
956 ],
957 &[
958 100.77777777777777,
959 101.87772165962352,
960 99.67783389593204,
961 103.52763748239212,
962 98.02791807316342,
963 ],
964 &[101.0, 101.0, 101.0, 101.0, 101.0],
965 &[103.0, 103.0, 103.0, 103.0, 103.0],
966 ],
967 },
968 ];
969
970 let weekdays = [
971 Weekday::Mon,
972 Weekday::Tue,
973 Weekday::Wed,
974 Weekday::Thu,
975 Weekday::Fri,
976 ];
977 let hour = |h| NaiveTime::from_hms_opt(h, 0, 0).unwrap();
978 let mut failures = Vec::new();
979 for case in cases {
980 let Some(zone) = TimezoneId::named(case.zone.unwrap_or("UTC")) else {
981 continue;
982 };
983 let bars: Vec<Bar> = case
984 .bars
985 .iter()
986 .map(|(local, [h, l, c, v])| {
987 let local = NaiveDateTime::parse_from_str(local, "%Y-%m-%d %H:%M").unwrap();
988 let time = zone.from_local_datetime(&local).single().unwrap();
989 Bar::new(time.with_timezone(&Utc), *c, *h, *l, *c, *v)
990 })
991 .collect();
992 let mut study = VolumeWeightedAvgPrice::new();
993 if let Some(name) = case.zone {
994 study = study.with_time_zone(TimezoneId::named(name).unwrap());
995 }
996 if let Some((open, close)) = case.window {
997 let window = SessionWindow::new(weekdays, hour(open), hour(close));
998 study = study.with_session(Session::new([window]));
999 }
1000 if case.anchor != VwapAnchor::default() {
1001 study = study.with_anchor(case.anchor);
1002 }
1003 study = study.with_std_dev_bands(case.bands.iter().copied());
1004 study.calculate(&bars);
1005
1006 let lines = 1 + 2 * case.bands.len();
1007 assert_eq!(study.line_count(), lines, "{}", case.name);
1008 let names = study.line_names();
1009 assert_eq!(names.len(), lines, "{}", case.name);
1010 assert_eq!(names[0], "VWAP");
1011 for (band, multiplier) in case.bands.iter().enumerate() {
1012 assert_eq!(names[1 + band * 2], format!("Upper {multiplier}σ"));
1013 assert_eq!(names[2 + band * 2], format!("Lower {multiplier}σ"));
1014 }
1015 assert_eq!(study.colors().len(), lines, "{}", case.name);
1016 assert_eq!(study.values().len(), case.want.len(), "{}", case.name);
1017 for (i, (got, want)) in study.values().to_rows().iter().zip(case.want).enumerate() {
1018 let got = match got {
1019 IndicatorValue::Single(v) if case.bands.is_empty() => vec![*v],
1020 IndicatorValue::Multiple(vs) if !case.bands.is_empty() => vs.clone(),
1021 other => panic!("{} bar {i}: {other:?}", case.name),
1022 };
1023 let close = got.len() == want.len()
1024 && got
1025 .iter()
1026 .zip(*want)
1027 .all(|(g, w)| (g - w).abs() <= 1e-12 * w.abs());
1028 if !close {
1029 failures.push(format!("{} bar {i}: got {got:?}, want {want:?}", case.name));
1030 }
1031 }
1032 }
1033 assert!(failures.is_empty(), "{}", failures.join("\n"));
1034 }
1035
1036 #[test]
1039 fn calendar_and_host_event_anchors_with_percentage_bands() {
1040 let times = [
1041 Utc.with_ymd_and_hms(2025, 12, 30, 12, 0, 0).unwrap(),
1042 Utc.with_ymd_and_hms(2025, 12, 31, 12, 0, 0).unwrap(),
1043 Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).unwrap(),
1044 ];
1045 for anchor in [VwapAnchor::Quarter, VwapAnchor::Year, VwapAnchor::Events] {
1046 let times = if anchor == VwapAnchor::Quarter {
1047 [
1048 Utc.with_ymd_and_hms(2026, 3, 30, 12, 0, 0).unwrap(),
1049 Utc.with_ymd_and_hms(2026, 3, 31, 12, 0, 0).unwrap(),
1050 Utc.with_ymd_and_hms(2026, 4, 1, 12, 0, 0).unwrap(),
1051 ]
1052 } else {
1053 times
1054 };
1055 let mut study = VolumeWeightedAvgPrice::new().with_anchor(anchor);
1056 if anchor == VwapAnchor::Events {
1057 let event = times[2] - chrono::Duration::hours(12);
1059 study = study.with_event_anchors([event, event, times[0]]);
1060 }
1061 study = study.with_percentage_bands([1.0]);
1062 assert_eq!(study.line_names(), ["VWAP", "Upper 1%", "Lower 1%"]);
1063 let bars: Vec<_> = times
1064 .into_iter()
1065 .zip([10.0, 14.0, 18.0])
1066 .map(|(t, c)| Bar::new(t, c, c, c, c, 1.0))
1067 .collect();
1068 study.calculate(&bars);
1069 for (got, want) in study.values().to_rows().iter().zip([10.0, 12.0, 18.0]) {
1070 let IndicatorValue::Multiple(lines) = got else {
1071 panic!("{got:?}")
1072 };
1073 for (actual, expected) in lines.iter().zip([want, want * 1.01, want * 0.99]) {
1074 assert!((actual - expected).abs() < 1e-12, "{anchor:?}: {got:?}");
1075 }
1076 }
1077 }
1078 }
1079
1080 #[cfg(feature = "widget")]
1082 #[cfg(feature = "timezones")]
1083 #[test]
1084 fn chart_supplies_the_exchange_session_to_vwap() {
1085 use crate::model::{BarData, Instrument};
1086 use crate::widget::{Chart, test_frames::shapes};
1087 let bars: Vec<_> = [23, 24, 25]
1088 .into_iter()
1089 .zip([10.0, 14.0, 18.0])
1090 .map(|(hour, c)| {
1091 Bar::new(
1092 Utc.with_ymd_and_hms(2026, 3, 2, 0, 0, 0).unwrap()
1093 + chrono::Duration::hours(hour),
1094 c,
1095 c,
1096 c,
1097 c,
1098 1.0,
1099 )
1100 })
1101 .collect();
1102 let instrument = Instrument::new("SESSION")
1103 .with_time_zone(TimezoneId::named("America/New_York").unwrap());
1104 for late in [false, true] {
1105 let mut chart = Chart::new(BarData::from_bars(bars.clone()));
1106 if !late {
1107 chart.set_instrument(Some(instrument.clone()));
1108 }
1109 chart.add_indicator(Box::new(VolumeWeightedAvgPrice::new()));
1110 if late {
1111 chart.set_instrument(Some(instrument.clone()));
1112 shapes(&egui::Context::default(), &mut chart, 0.0, Vec::new());
1113 }
1114 let study = chart.indicators().next().unwrap().1;
1115 assert!(
1116 matches!(study.values().to_rows()[2], IndicatorValue::Single(v) if v == 14.0),
1117 "late={late}: {:?}",
1118 study.values().to_rows()
1119 );
1120 chart.set_instrument(None);
1121 shapes(&egui::Context::default(), &mut chart, 1.0, Vec::new());
1122 let study = chart.indicators().next().unwrap().1;
1123 assert!(
1124 matches!(study.values().to_rows()[2], IndicatorValue::Single(v) if v == 16.0),
1125 "clearing metadata must restore UTC: {:?}",
1126 study.values().to_rows()
1127 );
1128 }
1129 let sparse = bars[..2].to_vec();
1131 for interval_first in [false, true] {
1132 let mut chart = Chart::new(BarData::from_bars(sparse.clone()));
1133 chart.set_instrument(Some(instrument.clone()));
1134 if interval_first {
1135 chart.set_timeframe(crate::model::Timeframe::Hour1);
1136 }
1137 chart.add_indicator(
1138 crate::studies::IndicatorFactory::new()
1139 .create("VWAP")
1140 .unwrap(),
1141 );
1142 chart.set_timeframe(crate::model::Timeframe::Hour1);
1143 shapes(&egui::Context::default(), &mut chart, 0.0, Vec::new());
1144 assert_eq!(
1145 chart.indicators().next().unwrap().1.values().value(0, 1),
1146 Some(12.0)
1147 );
1148 chart.set_timeframe(crate::model::Timeframe::Day1);
1149 shapes(&egui::Context::default(), &mut chart, 1.0, Vec::new());
1150 assert_eq!(
1151 chart.indicators().next().unwrap().1.values().value(0, 1),
1152 Some(14.0)
1153 );
1154 chart.set_timeframe(crate::model::Timeframe::Hour1);
1155 chart.set_symbol("UTC");
1156 chart.set_instrument(Some(Instrument::new("UTC")));
1157 shapes(&egui::Context::default(), &mut chart, 2.0, Vec::new());
1158 assert_eq!(
1159 chart.indicators().next().unwrap().1.values().value(0, 1),
1160 Some(14.0)
1161 );
1162 }
1163 let mut chart = Chart::new(BarData::from_bars(bars));
1164 chart.add_indicator(Box::new(
1165 VolumeWeightedAvgPrice::new()
1166 .with_time_zone(TimezoneId::named("America/New_York").unwrap()),
1167 ));
1168 shapes(&egui::Context::default(), &mut chart, 0.0, Vec::new());
1169 assert!(matches!(
1170 chart.indicators().next().unwrap().1.values().to_rows()[2],
1171 IndicatorValue::Single(v) if v == 14.0
1172 ));
1173 }
1174
1175 #[cfg(feature = "widget")]
1179 #[test]
1180 fn vwap_restarts_at_the_chart_events_of_its_anchor() {
1181 use crate::model::BarData;
1182 use crate::widget::{Chart, test_frames::shapes};
1183 let bars: Vec<_> = [
1184 (100.0, 1000.0),
1185 (102.0, 1000.0),
1186 (104.0, 2000.0),
1187 (106.0, 1000.0),
1188 ]
1189 .into_iter()
1190 .zip(2..)
1191 .map(|((price, volume), day)| {
1192 let time = Utc.with_ymd_and_hms(2026, 3, day, 0, 0, 0).unwrap();
1193 Bar::new(time, price, price, price, price, volume)
1194 })
1195 .collect();
1196 let event = |kind, time| TimeScaleEvent {
1197 id: format!("{kind:?}"),
1198 time,
1199 kind,
1200 title: String::new(),
1201 details: String::new(),
1202 };
1203 let mut chart = Chart::new(BarData::from_bars(bars));
1204 chart.set_time_scale_events(vec![
1205 event(
1206 EventKind::Earnings,
1207 Utc.with_ymd_and_hms(2026, 3, 4, 0, 0, 0).unwrap(),
1208 ),
1209 event(
1210 EventKind::Dividend,
1211 Utc.with_ymd_and_hms(2026, 3, 2, 12, 0, 0).unwrap(),
1212 ),
1213 ]);
1214 let id = chart.add_indicator(
1215 crate::studies::IndicatorFactory::new()
1216 .create("VWAP")
1217 .unwrap(),
1218 );
1219 let ctx = egui::Context::default();
1220 let values = |chart: &Chart| -> Vec<f64> {
1221 let study = chart.indicators().next().unwrap().1;
1222 (0..4)
1223 .map(|bar| study.values().value(0, bar).unwrap())
1224 .collect()
1225 };
1226 let assert_close = |got: Vec<f64>, want: [f64; 4]| {
1227 for (got, want) in got.iter().zip(want) {
1228 assert!((got - want).abs() < 1e-9, "{got} != {want}");
1229 }
1230 };
1231 let earnings = [100.0, 101.0, 104.0, 314000.0 / 3000.0];
1232 let dividends = [100.0, 102.0, 310000.0 / 3000.0, 104.0];
1233 let none = [100.0, 101.0, 102.5, 103.2];
1234 for (anchor, want, now) in [(9, dividends, 1.0), (10, none, 2.0), (8, earnings, 3.0)] {
1235 chart
1236 .indicator_mut(id)
1237 .unwrap()
1238 .set_input("anchor", InputValue::Choice(anchor))
1239 .unwrap();
1240 shapes(&ctx, &mut chart, now, Vec::new());
1241 assert_close(values(&chart), want);
1242 }
1243 chart.set_time_scale_events(Vec::new());
1245 shapes(&ctx, &mut chart, 4.0, Vec::new());
1246 assert_close(values(&chart), none);
1247 }
1248
1249 #[test]
1252 fn extreme_bar_times_do_not_panic_in_a_zone() {
1253 for (zone, time) in [
1254 ("+11:00", DateTime::<Utc>::MAX_UTC),
1255 ("-06:00", DateTime::<Utc>::MIN_UTC),
1256 ] {
1257 let mut study =
1258 VolumeWeightedAvgPrice::new().with_time_zone(TimezoneId::named(zone).unwrap());
1259 study.calculate(&[Bar::new(time, 1.0, 1.0, 1.0, 1.0, 1.0)]);
1260 assert_eq!(study.values().len(), 1, "{zone}");
1261 }
1262 }
1263
1264 #[cfg(feature = "timezones")]
1269 #[test]
1270 fn event_anchors_are_edited_through_inputs_in_their_time_zone() {
1271 use crate::studies::InputValue::{Length, Text};
1272 let bars: Vec<_> = [(13, 10.0, 1.0), (14, 20.0, 1.0), (15, 40.0, 2.0)]
1273 .into_iter()
1274 .map(|(hour, price, volume)| {
1275 let time = Utc.with_ymd_and_hms(2026, 3, 2, hour, 0, 0).unwrap();
1276 Bar::new(time, price, price, price, price, volume)
1277 })
1278 .collect();
1279 let mut study = VolumeWeightedAvgPrice::new();
1280 study.set_input("anchor", InputValue::Choice(5)).unwrap();
1281 study
1282 .set_input("event_time_zone", Text("America/New_York".into()))
1283 .unwrap();
1284 study.set_input("event_count", Length(3)).unwrap();
1285 for (i, time) in [
1286 "2026-03-02 09:00:00",
1287 "2026-03-02 08:00",
1288 "2026-03-02 09:00:00",
1289 ]
1290 .into_iter()
1291 .enumerate()
1292 {
1293 study
1294 .set_input(&format!("event_{i}"), Text(time.into()))
1295 .unwrap();
1296 }
1297 assert!(study.set_input("event_0", Text("tomorrow".into())).is_err());
1298 study.calculate(&bars);
1299 let last = study.values().value(0, 2).unwrap();
1300 assert!((last - 100.0 / 3.0).abs() < 1e-12, "{last}");
1301 }
1302
1303 #[test]
1306 fn bands_and_events_survive_a_template_and_resize_styles() {
1307 use crate::studies::{ConfiguredStudy, InputValue::Length, StudyTemplate};
1308 let event = Utc.with_ymd_and_hms(2026, 3, 2, 14, 30, 0).unwrap();
1309 let study = VolumeWeightedAvgPrice::new()
1310 .with_percentage_bands([0.5, 2.5])
1311 .with_event_anchors([event]);
1312 let study = ConfiguredStudy::new(Box::new(study));
1313 let restored = StudyTemplate::from_study(&study).create().unwrap();
1314 let inputs = |s: &dyn Indicator| {
1315 s.inputs()
1316 .into_iter()
1317 .map(|i| (i.id, i.value))
1318 .collect::<Vec<_>>()
1319 };
1320 assert_eq!(inputs(&study), inputs(&*restored));
1321 assert_eq!(restored.line_count(), 5);
1322
1323 let mut edited = study.clone();
1324 let mut settings = edited.settings();
1325 settings.plots[0].width = 3.0;
1326 edited
1327 .apply_settings(&[("band_count".into(), Length(3))], settings)
1328 .unwrap();
1329 assert_eq!(edited.line_count(), 7);
1330 let plots = edited.settings().plots;
1331 assert_eq!(plots.len(), 7);
1332 assert_eq!(plots[0].width, 3.0);
1333 let bands: Vec<_> = edited
1334 .inputs()
1335 .into_iter()
1336 .filter(|i| i.id.starts_with("band_") && i.id != "band_count")
1337 .map(|i| i.value)
1338 .collect();
1339 assert_eq!(bands, [0.5, 2.5, 3.0].map(InputValue::Number));
1340 }
1341}