Skip to main content

egui_charts/studies/builtin/
vwap.rs

1//! Volume Weighted Average Price (VWAP) Indicator
2//!
3//! VWAP is the average price weighted by volume over an anchor period: a trading
4//! session, a calendar period or a host-supplied event. It restarts at the first bar of each period and is
5//! commonly used as an execution benchmark.
6//!
7//! # Interpretation
8//! - Price above VWAP: Bullish bias
9//! - Price below VWAP: Bearish bias
10//! - VWAP can act as support/resistance
11//! - Institutional traders often use VWAP as execution benchmark
12//!
13//! # Calculation
14//! VWAP = Sum(Typical Price * Volume) / Sum(Volume) since the start of the period
15//! Typical Price = (High + Low + Close) / 3
16//! Band = VWAP +/- k * sigma, where sigma^2 = Sum(Volume * Typical Price^2) / Sum(Volume) - VWAP^2
17//!
18//! # Example
19//! ```
20//! # #[cfg(feature = "timezones")] {
21//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
22//! use chrono::{NaiveTime, Weekday};
23//! use egui_charts::model::{Session, SessionWindow, TimezoneId};
24//! use egui_charts::studies::{Indicator, VolumeWeightedAvgPrice};
25//!
26//! // Sessions from 17:00 to 16:00 Chicago time, each named by the day it closes.
27//! let weekdays = [Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu, Weekday::Fri];
28//! let hour = |h| NaiveTime::from_hms_opt(h, 0, 0).unwrap();
29//! let mut vwap = VolumeWeightedAvgPrice::new()
30//!     .with_time_zone(TimezoneId::named("America/Chicago").unwrap())
31//!     .with_session(Session::new([SessionWindow::new(weekdays, hour(17), hour(16))]))
32//!     .with_std_dev_bands([1.0, 2.0]);
33//! vwap.calculate(&bars);
34//! assert_eq!(vwap.line_count(), 5);
35//! # }
36//! ```
37
38use 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/// The period over which [`VolumeWeightedAvgPrice`] accumulates before it restarts.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum VwapAnchor {
52    /// Each trading day.
53    #[default]
54    Session,
55    /// Each ISO week, Monday to Sunday, of trading days.
56    Week,
57    /// Each calendar month of trading days.
58    Month,
59    /// Each calendar quarter of trading days.
60    Quarter,
61    /// Each calendar year of trading days.
62    Year,
63    /// Restart at timestamps the host supplies with `with_event_anchors` or types into the inputs.
64    Events,
65    /// Each decade of trading days, 2020 to 2029 for example.
66    Decade,
67    /// Each century of trading days, 2000 to 2099 for example.
68    Century,
69    /// Restart at each earnings event the host gives the chart for its symbol.
70    Earnings,
71    /// Restart at each dividend event the host gives the chart for its symbol.
72    Dividends,
73    /// Restart at each split event the host gives the chart for its symbol.
74    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
91/// The most bands that the study inputs can hold.
92const MAX_BANDS: usize = 10;
93/// The most event anchors that the study inputs can hold.
94const MAX_EVENTS: usize = 1000;
95
96/// The local date and time of an event anchor in inputs, to the second.
97const EVENT_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
98
99/// The index in a slot input id such as `band_2`.
100fn slot(id: &str, prefix: &str) -> Option<usize> {
101    id.strip_prefix(prefix)?.parse().ok()
102}
103
104/// Minutes after midnight of the earliest open of a window in `windows` that spans midnight,
105/// where [`trading_day`] starts the next trading day, or 0 for none: all it reads of them.
106fn 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
115/// The one window that starts each trading day `minutes` after midnight; none at midnight.
116fn 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    /// Identifies the period that holds trading day `day`.
131    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
145/// The trading day of `time`: its date in `zone`, or the next date from the open of a
146/// window that spans midnight, since a window is named by the day on which it closes.
147pub(super) fn trading_day(
148    time: DateTime<Utc>,
149    zone: &TimezoneId,
150    windows: &[SessionWindow],
151) -> NaiveDate {
152    let utc = time.naive_utc();
153    // Near the ends of the representable range the local time may not exist; keep UTC there.
154    let local = utc
155        .checked_add_offset(zone.offset_from_utc_datetime(&utc).fix())
156        .unwrap_or(utc);
157    let date = local.date();
158    // A window from 00:00 to 00:00 lies within its opening day.
159    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/// Volume Weighted Average Price, restarted at each anchor period, with optional
170/// standard deviation bands.
171///
172/// [`new`](Self::new) restarts at each UTC day until a time zone is set with
173/// [`with_time_zone`](Self::with_time_zone). Pass the instrument's
174/// [`time_zone`](crate::model::Instrument::time_zone) and
175/// [`session`](crate::model::Instrument::session) so that sessions which cross
176/// midnight in the exchange zone, or UTC midnight, stay one period.
177#[derive(Clone)]
178pub struct VolumeWeightedAvgPrice {
179    values: IndicatorValues,
180    stepper: Stepper<State>,
181    /// First bar sharing the preceding bar's UTC date, if any.
182    shared: Option<usize>,
183    /// Whether no two consecutive bars shared a UTC date at the last update.
184    daily: bool,
185    timeframe: Option<Timeframe>,
186    /// One per line: the VWAP, then the upper and lower band of each multiplier.
187    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    /// The zone in which the event anchor inputs are written.
197    event_zone: TimezoneId,
198    /// The earnings, dividends and splits the chart holds for its symbol.
199    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    /// A VWAP restarted at each session, in UTC with no session windows, without bands.
213    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    /// Restarts at each selected calendar period or host event.
235    pub fn with_anchor(mut self, anchor: VwapAnchor) -> Self {
236        self.anchor = anchor;
237        self
238    }
239
240    /// The exchange time zone, in which trading days are counted. A bar shorter than a
241    /// day counts on the exchange date of its timestamp. A bar of a day or longer, which
242    /// a feed stamps inside its UTC period ([`HistoryPage::bars`](crate::data::HistoryPage::bars)),
243    /// counts on its UTC date. Without an explicit timeframe, bars are taken to be a
244    /// day or longer when no two consecutive bars share a UTC date.
245    pub fn with_time_zone(mut self, time_zone: TimezoneId) -> Self {
246        self.time_zone = time_zone;
247        self
248    }
249
250    /// The trading hours, in exchange time. A window that spans midnight starts the next
251    /// trading day at its open. The study keeps the earliest such open, to the minute, as its
252    /// trading day start input, which is all the calculation reads of the session.
253    pub fn with_session(mut self, session: Session) -> Self {
254        self.windows = day_start_windows(day_start(&session.windows));
255        self
256    }
257
258    /// Adds an upper and a lower band at each multiple of the volume-weighted
259    /// standard deviation of the typical price over the period. Multipliers clamp
260    /// to 0 or more, NaN to 0, and at most ten are kept. The bands are drawn in the VWAP
261    /// colour at half intensity.
262    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    /// One colour per line, with green, olive and dark teal for successive band pairs.
275    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    /// Adds symmetric bands at percentages of the absolute VWAP. Percentages
288    /// clamp to 0 or more, NaN to 0, and replace any standard deviation bands.
289    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    /// Restart on the first bar at or after each event timestamp. The host supplies
296    /// the chosen earnings, dividend or split events; no event feed is requested.
297    /// Timestamps are sorted and deduplicated. Selects `VwapAnchor::Events`. The study
298    /// inputs write them in the zone of the "Event time zone" input, UTC by default.
299    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    /// Set the VWAP line color; the bands take it at half intensity.
308    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    /// `band_count` and `event_count` resize their lists, so they come first among the slots
411    /// of an applied set of inputs; a slot past the end of its list has nothing to edit and is
412    /// accepted unchanged. Times are read in the current "Event time zone", and a repeated
413    /// local time takes its first occurrence. Events are applied sorted and deduplicated.
414    /// `time_zone` and `day_start` count trading days while the chart passes no instrument; an
415    /// instrument's time zone and session, given with
416    /// [`set_instrument`](Indicator::set_instrument), take their place.
417    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        // Older templates stored this inferred flag; it never controlled the calculation.
424        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        // The daily rule reads all bars, including those with unusable prices or volume.
513        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        // Inputs keep the events in the order they were written.
533        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                // A bar with a non-finite price or volume has no value and adds nothing.
547                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                    // Clamped: cancellation can leave a tiny negative variance.
576                    (
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        // Called every frame: no allocation while the events are unchanged.
644        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    /// Sets the colors line by line; lines without a color keep theirs.
667    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 // VWAP is drawn on the main chart
675    }
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        /// `None` keeps the UTC default of `new()`.
712        zone: Option<&'static str>,
713        /// Open and close hour of one window, Monday to Friday.
714        window: Option<(u32, u32)>,
715        anchor: VwapAnchor,
716        bands: &'static [f64],
717        /// Exchange-local time, then high, low, close and volume.
718        bars: &'static [(&'static str, [f64; 4])],
719        /// VWAP, then the upper and lower band of each multiplier.
720        want: &'static [&'static [f64]],
721    }
722
723    /// Expected values come from the anchored VWAP definition, sum(tp * v) / sum(v) with
724    /// tp = (h + l + c) / 3 restarted at each period (Berkowitz, Logue and Noser, "The Total
725    /// Cost of Transactions on the NYSE", Journal of Finance 43(1), 1988), with bands at k
726    /// volume-weighted population standard deviations of tp, and trading days named by the
727    /// day on which their session window closes in the exchange zone. They are computed
728    /// independently with exact fractions.
729    #[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                // The 10:00 open is 23:00 UTC on the previous day.
753                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                // The 17:00 open starts the next trading day; UTC midnight is 18:00.
773                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                // Intraday bars: Sunday 17:00, the day daylight saving starts, opens Monday's session.
795                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                // Daily bars at 00:00 UTC on Thu 4, Fri 5, Mon 8 and Tue 9 January, 19:00 the
817                // evening before in New York: the week restarts on the Monday bar.
818                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                // The forming daily bar pushed with its trade time: Tuesday's last push at
833                // 20:59 UTC, then Wednesday's first at 14:30 UTC, after daily bars at 00:00 UTC.
834                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                // Daily bars at 23:00 UTC on 30 and 31 January and 1 and 2 February, 08:00 the
856                // next morning in Tokyo: the month restarts on the 1 February bar.
857                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                // Intraday bars in a 24-hour session from 00:00 to 00:00: each keeps its own date.
886                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                // No restart at the year, a restart at the decade.
908                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                // No restart at the decade, a restart at the century.
922                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                // The second day opens on a bar without volume: its typical price.
936                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    /// Equal volumes at 10 and 14 give 12; a reset then starts at 18.
1037    /// One-percent bands are 0.10, 0.12 and 0.18 from the VWAP.
1038    #[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                // Duplicate events in a closed-market gap still cause exactly one reset.
1058                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    /// A dialog-created VWAP inherits metadata both when added and when metadata arrives later.
1081    #[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        // These two intraday bars straddle UTC midnight but share a New York date.
1130        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    /// Daily bars from 2 March with typical prices 100, 102, 104, 106 and volumes 1000, 1000,
1176    /// 2000, 1000; earnings on the third bar's time and a dividend inside the first bar's day, so
1177    /// restarting on the second bar, the first at or after it.
1178    #[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        // Earnings are the anchor from the last round; a change of events recalculates.
1244        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    /// The local time of a bar at either end of the representable range is out of range
1250    /// in a zone east or west of UTC; `calculate` must still not panic.
1251    #[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    /// Hourly bars at 13:00, 14:00 and 15:00 UTC with prices 10, 20, 40 and volumes 1, 1, 2.
1265    /// Events typed in New York, five hours behind UTC before 8 March, as 09:00 twice and 08:00
1266    /// are the instants 14:00 and 13:00 UTC once each: the average restarts at 14:00, so the last
1267    /// bar is (20 + 2 * 40) / 3, where an event read as UTC would leave (10 + 20 + 80) / 4.
1268    #[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    /// Bands and events are inputs that a template restores, with one style per resulting line,
1304    /// and editing the number of bands through a chart's settings resizes the styles.
1305    #[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}