Skip to main content

egui_charts/studies/builtin/
volume_24h.rs

1//! 24-hour Volume
2//!
3//! The volume traded over the last 24 hours at each bar, whatever the chart interval: a
4//! rolling sum over a fixed span of calendar time rather than a number of bars, so a
5//! 1-minute and a 1-hour chart of the same market show the same turnover.
6//!
7//! # Calculation
8//! On an interval that divides a day,
9//!
10//! Volume24h(t) = Sum(volume of the bars that open in (t - 24 h, t])
11//!
12//! where `t` is the open time of the current bar. A bar that opens exactly 24 hours before
13//! the current one belongs to the previous day, so the window holds exactly one day of bars,
14//! and on daily bars it holds the bar alone.
15//!
16//! Bars longer than a day, or of an interval that does not divide a day, cannot be split into
17//! the last 24 hours, so on those intervals the study requests lower-interval candles through
18//! [`Indicator::data_requests`]: one hour above a day, one minute below it and one second below
19//! a minute, or one second on an interval that is not a whole number of those. Each bar then sums the candles that open in the 24 hours before the close of its
20//! last candle: the bar's own close when trading runs to its end, and otherwise its last
21//! trading hours, so a weekly bar of a market closed at the weekend sums its last trading day.
22//! A bar without candles has no value. Until candles are supplied the study sums chart bars,
23//! which has no value on bars longer than a day.
24//!
25//! The Units input defaults to supplied volume. Quote currency assumes the host supplies
26//! volume in base units and sums `volume * close` for each contributing bar, never the
27//! total volume times the chart bar's close. This is an estimate from candles, not execution
28//! turnover. The host must supply [`crate::model::Instrument::currency`] to identify the quote
29//! currency; already quoted volume or contract counts must use supplied units. Negative base
30//! volume or a non-finite volume, close or product makes a quote window unavailable until that
31//! bar leaves it. Quote readouts end with the currency code, and legends, tooltips and exports name
32//! the selected units.
33//!
34//! # Example
35//! ```
36//! use chrono::{TimeZone, Utc};
37//! use egui_charts::model::Bar;
38//! use egui_charts::studies::{Indicator, Volume24h};
39//!
40//! // Hourly bars of 10 units: from the 25th bar on, the oldest bar leaves the window.
41//! let bars: Vec<Bar> = (0..30)
42//!     .map(|h| Bar::new(Utc.timestamp_opt(h * 3_600, 0).unwrap(), 1.0, 1.0, 1.0, 1.0, 10.0))
43//!     .collect();
44//! let mut study = Volume24h::new();
45//! study.calculate(&bars);
46//! assert_eq!(study.values().value(0, 0), Some(10.0));
47//! assert_eq!(study.values().value(0, 23), Some(240.0));
48//! assert_eq!(study.values().value(0, 29), Some(240.0));
49//! ```
50
51use super::lower_interval;
52use crate::model::{Bar, BarClock, Timeframe};
53use crate::studies::palette;
54use crate::studies::stepper::Stepper;
55use crate::studies::{
56    Indicator, IndicatorValue, IndicatorValues, InputKind, InputValue, StudyCell, StudyData,
57    StudyDataRequest, StudyInput, StudyTable,
58};
59use chrono::{DateTime, TimeDelta, Utc};
60use egui::Color32;
61use std::collections::VecDeque;
62
63/// Rolling volume over the last 24 hours of calendar time, in a separate pane.
64///
65/// The `units` choice input is 0 for supplied volume (default), 1 for quote currency.
66/// Quote mode requires base-unit volumes and the instrument's price currency metadata.
67///
68/// In supplied units a non-finite volume has no value and adds nothing; in quote units it
69/// makes its entire window unavailable. The chart supplies its
70/// interval through [`Indicator::set_bar_clock`] and the candles through
71/// [`Indicator::update_data`]; a direct caller on bars longer than a day passes a clock of that
72/// interval, without which every bar is summed as an intraday bar.
73#[derive(Clone)]
74pub struct Volume24h {
75    values: IndicatorValues,
76    stepper: Stepper<State>,
77    /// The chart clock, from `set_bar_clock`.
78    clock: BarClock,
79    /// Whether `values` were summed from lower-interval candles rather than chart bars.
80    from_candles: bool,
81    quote_units: bool,
82    currency: Option<String>,
83    precision: Option<usize>,
84    /// The report shown when quote units have no latest value.
85    table: Option<StudyTable>,
86    color: Color32,
87    visible: bool,
88}
89
90#[derive(Clone, Default)]
91struct State {
92    /// Open time and amount of each bar in the window, oldest first; None is invalid quote data.
93    window: VecDeque<(DateTime<Utc>, Option<f64>)>,
94    sum: f64,
95    invalid: usize,
96}
97
98impl Volume24h {
99    /// A 24-hour volume study.
100    pub fn new() -> Self {
101        Self {
102            values: IndicatorValues::new(),
103            stepper: Stepper::default(),
104            clock: BarClock::new(Timeframe::Min1),
105            from_candles: false,
106            quote_units: false,
107            currency: None,
108            precision: None,
109            table: None,
110            color: palette::BLUE,
111            visible: true,
112        }
113    }
114
115    fn clear(&mut self) {
116        self.values.clear();
117        self.stepper = Stepper::default();
118        self.from_candles = false;
119        self.update_table();
120    }
121
122    fn amount(bar: &Bar, quote_units: bool) -> Option<f64> {
123        let amount = if quote_units {
124            if !bar.close.is_finite() || bar.volume < 0.0 {
125                return None;
126            }
127            bar.volume * bar.close
128        } else {
129            bar.volume
130        };
131        amount.is_finite().then_some(amount)
132    }
133
134    fn unit(&self) -> &str {
135        if self.quote_units {
136            self.currency
137                .as_deref()
138                .unwrap_or("quote currency unavailable")
139        } else {
140            "supplied units"
141        }
142    }
143
144    fn update_table(&mut self) {
145        let unavailable = self
146            .values
147            .len()
148            .checked_sub(1)
149            .is_some_and(|i| self.quote_units && self.values.value(0, i).is_none());
150        self.table = unavailable.then(|| StudyTable {
151            title: "24-hour Volume".into(),
152            columns: vec![self.line_names().remove(0)],
153            rows: vec![vec![StudyCell::Text(if self.currency.is_none() {
154                "Quote currency unavailable".into()
155            } else {
156                "Quote amount unavailable".into()
157            })]],
158        });
159    }
160
161    /// The lower interval read where chart bars cannot hold exactly the last 24 hours: on
162    /// intervals longer than a day or that do not divide it.
163    fn lower_timeframe(&self) -> Option<Timeframe> {
164        let span = self.clock.timeframe()?.duration_ms();
165        let day = Timeframe::Day1.duration_ms();
166        if span > 0 && span <= day && day % span == 0 {
167            return None;
168        }
169        lower_interval::lower_timeframe(&self.clock, None)
170    }
171
172    /// Sums, for each bar of `data[from..]`, the candles that open in the 24 hours before the
173    /// close of its last candle.
174    fn sum_candles(&mut self, data: &[Bar], from: usize, lower: Timeframe, candles: &[Bar]) {
175        let from = if self.from_candles { from } else { 0 };
176        self.from_candles = true;
177        if from == 0 {
178            self.values = IndicatorValues::missing(data.len());
179        } else {
180            self.values.truncate(from);
181            self.values.resize(data.len(), IndicatorValue::None);
182        }
183        let ranges = lower_interval::nest(&self.clock, lower, data, candles, from);
184        for (i, range) in (from..).zip(ranges) {
185            if range.is_empty() || (!self.quote_units && !data[i].close.is_finite()) {
186                continue;
187            }
188            let last = range.end - 1;
189            let end = candles[last]
190                .time
191                .checked_add_signed(lower.duration())
192                .unwrap_or(DateTime::<Utc>::MAX_UTC);
193            let start = end
194                .checked_sub_signed(TimeDelta::days(1))
195                .unwrap_or(DateTime::<Utc>::MIN_UTC);
196            let first = candles[..last].partition_point(|candle| candle.time < start);
197            // shortcut: sums each window afresh, up to 86,400 candles a bar on one-second
198            // candles (intervals of a few seconds that do not divide the day); slide the window
199            // if such charts become slow.
200            let sum = if self.quote_units {
201                candles[first..range.end]
202                    .iter()
203                    .map(|candle| Self::amount(candle, true))
204                    .sum::<Option<f64>>()
205                    .filter(|sum| sum.is_finite())
206                    .filter(|_| self.currency.is_some())
207            } else {
208                Some(
209                    candles[first..range.end]
210                        .iter()
211                        .map(|candle| candle.volume)
212                        .sum(),
213                )
214            };
215            if let Some(sum) = sum {
216                self.values.set_value(0, i, sum);
217            }
218        }
219        self.update_table();
220    }
221}
222
223impl Default for Volume24h {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl Indicator for Volume24h {
230    fn inputs(&self) -> Vec<StudyInput> {
231        vec![
232            StudyInput::new(
233                "units",
234                "Units",
235                InputValue::Choice(usize::from(self.quote_units)),
236                InputKind::Choice(vec![
237                    "Supplied volume".into(),
238                    "Quote currency (volume in base units)".into(),
239                ]),
240            )
241            .with_default(InputValue::Choice(0)),
242        ]
243    }
244
245    fn set_input(&mut self, id: &str, value: InputValue) -> Result<(), crate::studies::StudyError> {
246        if id != "units" {
247            return Err(crate::studies::StudyError::UnknownInput(id.into()));
248        }
249        let InputValue::Choice(units @ 0..=1) = value else {
250            return Err(crate::studies::StudyError::invalid_input(id));
251        };
252        self.quote_units = units == 1;
253        self.clear();
254        Ok(())
255    }
256
257    fn name(&self) -> &str {
258        "24-hour Volume"
259    }
260
261    fn desc(&self) -> &str {
262        "24-hour Volume - Volume traded over the last 24 hours, on any interval"
263    }
264
265    fn value_format(&self) -> crate::scales::StudyFormat {
266        if self.quote_units {
267            crate::scales::StudyFormat::Fixed(2)
268        } else {
269            crate::scales::StudyFormat::Volume
270        }
271    }
272
273    fn precision(&self) -> Option<usize> {
274        self.precision.or(self.quote_units.then_some(2))
275    }
276
277    fn set_label_precision(&mut self, precision: Option<usize>) {
278        self.precision = precision;
279        self.update_table();
280    }
281
282    fn format_value(&self, value: f64) -> String {
283        let value = self.value_format().format(value, self.precision());
284        match &self.currency {
285            Some(currency) if self.quote_units => format!("{value} {currency}"),
286            _ => value,
287        }
288    }
289
290    fn line_names(&self) -> Vec<String> {
291        vec![format!(
292            "{} ({})",
293            if self.quote_units { "Amount" } else { "Volume" },
294            self.unit()
295        )]
296    }
297
298    fn table(&self) -> Option<&StudyTable> {
299        self.table.as_ref()
300    }
301
302    fn set_instrument(&mut self, instrument: Option<&crate::model::Instrument>) -> bool {
303        let currency = instrument
304            .and_then(|i| i.currency.as_deref())
305            .map(str::trim)
306            .filter(|s| !s.is_empty());
307        if self.currency.as_deref() == currency {
308            return false;
309        }
310        self.currency = currency.map(str::to_owned);
311        self.clear();
312        true
313    }
314
315    fn plot_style(&self, _line: usize) -> crate::studies::PlotStyle {
316        crate::studies::PlotStyle {
317            color: self.color,
318            width: 1.0,
319            pattern: crate::model::LineStyle::Solid,
320            plot_type: crate::studies::PlotType::Columns,
321            visible: true,
322        }
323    }
324
325    fn calculate(&mut self, data: &[Bar]) {
326        self.update(data, 0);
327    }
328
329    fn update(&mut self, data: &[Bar], from: usize) {
330        if std::mem::take(&mut self.from_candles) {
331            // Chart bars replace candle sums from the first bar.
332            self.values.clear();
333            self.stepper = Stepper::default();
334        }
335        let long_bars = self
336            .clock
337            .timeframe()
338            .is_some_and(|timeframe| timeframe.duration_ms() > Timeframe::Day1.duration_ms());
339        let quote_units = self.quote_units;
340        let available = !quote_units || self.currency.is_some();
341        self.stepper
342            .run(data, from, &mut self.values, |state, bar| {
343                if long_bars || !available || (!quote_units && !bar.volume.is_finite()) {
344                    return IndicatorValue::None;
345                }
346                let start = bar
347                    .time
348                    .checked_sub_signed(TimeDelta::days(1))
349                    .unwrap_or(DateTime::<Utc>::MIN_UTC);
350                let mut largest = 0.0_f64;
351                while let Some(&(time, volume)) = state.window.front()
352                    && time <= start
353                {
354                    state.window.pop_front();
355                    if let Some(volume) = volume {
356                        state.sum -= volume;
357                        largest = largest.max(volume.abs());
358                    } else {
359                        state.invalid -= 1;
360                    }
361                }
362                if largest > state.sum.abs() {
363                    // A removed volume that dominated the sum leaves its rounding behind:
364                    // add the window again so the error does not linger.
365                    state.sum = state.window.iter().filter_map(|&(_, volume)| volume).sum();
366                }
367                let amount = Self::amount(bar, quote_units);
368                state.window.push_back((bar.time, amount));
369                if let Some(amount) = amount {
370                    state.sum += amount;
371                } else {
372                    state.invalid += 1;
373                }
374                // Overflow of a finite sum must not poison later ordinary windows.
375                if !state.sum.is_finite() {
376                    state.sum = state.window.iter().filter_map(|&(_, volume)| volume).sum();
377                }
378                if state.invalid == 0 && state.sum.is_finite() {
379                    IndicatorValue::Single(state.sum)
380                } else {
381                    IndicatorValue::None
382                }
383            });
384        self.update_table();
385    }
386
387    fn set_bar_clock(&mut self, clock: &BarClock) -> bool {
388        let changed = self.clock != *clock;
389        if changed {
390            self.clock = clock.clone();
391            self.clear();
392        }
393        changed
394    }
395
396    fn data_requests(&self) -> Vec<StudyDataRequest> {
397        self.lower_timeframe()
398            .map(lower_interval::request)
399            .into_iter()
400            .collect()
401    }
402
403    fn update_data(&mut self, data: &[Bar], from: usize, sources: &[StudyData<'_>]) {
404        let Some(lower) = self.lower_timeframe() else {
405            return self.update(data, from);
406        };
407        let from = from.min(self.values.len()).min(data.len());
408        let request = lower_interval::request(lower);
409        let candles = if self.quote_units {
410            // Validate the series here: invalid amounts affect their own windows, not all history.
411            match sources.iter().find(|source| source.request == request) {
412                Some(source)
413                    if source.timeframe != lower
414                        || (from == 0
415                            && source
416                                .bars
417                                .windows(2)
418                                .any(|pair| pair[0].time >= pair[1].time)) =>
419                {
420                    None
421                }
422                Some(source) => Some(source.bars),
423                None => Some(&[][..]),
424            }
425        } else {
426            lower_interval::reply(&request, from, sources)
427        };
428        match candles {
429            None if self.quote_units => {
430                self.clear();
431                self.values = IndicatorValues::missing(data.len());
432                self.update_table();
433            }
434            None => {}
435            Some([]) => self.update(data, from),
436            Some(candles) => self.sum_candles(data, from, lower, candles),
437        }
438    }
439
440    fn is_incremental(&self) -> bool {
441        true
442    }
443
444    fn values(&self) -> &IndicatorValues {
445        &self.values
446    }
447
448    fn colors(&self) -> Vec<Color32> {
449        vec![self.color]
450    }
451
452    fn set_colors(&mut self, colors: Vec<Color32>) {
453        if let Some(&color) = colors.first() {
454            self.color = color;
455        }
456    }
457
458    fn is_overlay(&self) -> bool {
459        false
460    }
461
462    fn is_visible(&self) -> bool {
463        self.visible
464    }
465
466    fn set_visible(&mut self, visible: bool) {
467        self.visible = visible;
468    }
469
470    fn clone_box(&self) -> Box<dyn Indicator> {
471        Box::new(self.clone())
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use chrono::TimeZone;
479
480    /// Both paths use each contributing bar's close, and an unusable quote amount stays
481    /// missing until that candle expires rather than silently becoming supplied volume.
482    #[test]
483    fn units_use_each_candle_close_and_recover_after_expiry() {
484        use crate::model::Instrument;
485        use crate::studies::{InputValue, StudyCell};
486        let bar = |h: i64, volume, close| {
487            Bar::new(
488                Utc.timestamp_opt(h * 3_600, 0).unwrap(),
489                close,
490                close,
491                close,
492                close,
493                volume,
494            )
495        };
496        let five_hours = Timeframe::Custom(std::num::NonZeroU64::new(18_000).unwrap());
497        // 2 + 3 = 5; 2×10 + 3×20 = 80. At hour 24 the first bar expires:
498        // 3 + 4 = 7 and 3×20 + 4×30 = 180. Lower candles end at their close.
499        for (interval, data, candles, supplied, quote) in [
500            (
501                Timeframe::Hour1,
502                vec![
503                    bar(0, 2.0, 10.0),
504                    bar(1, 3.0, 20.0),
505                    bar(24, 4.0, 30.0),
506                    bar(25, 1.0, 40.0),
507                ],
508                vec![],
509                vec![Some(2.0), Some(5.0), Some(7.0), Some(5.0)],
510                vec![Some(20.0), Some(80.0), Some(180.0), Some(160.0)],
511            ),
512            (
513                five_hours,
514                vec![bar(0, 99.0, 100.0), bar(24, 99.0, 100.0)],
515                vec![
516                    bar(0, 2.0, 10.0),
517                    bar(1, 3.0, 20.0),
518                    bar(24, 4.0, 30.0),
519                    bar(25, 1.0, 40.0),
520                ],
521                vec![Some(5.0), Some(5.0)],
522                vec![Some(80.0), Some(160.0)],
523            ),
524            (
525                Timeframe::Week1,
526                vec![bar(4 * 24, 99.0, 100.0), bar(11 * 24, 99.0, 100.0)],
527                vec![
528                    bar(7 * 24 + 15, 7.0, 5.0),
529                    bar(7 * 24 + 16, 2.0, 10.0),
530                    bar(8 * 24 + 15, 3.0, 20.0),
531                    bar(17 * 24, 4.0, 30.0),
532                    bar(17 * 24 + 23, 1.0, 40.0),
533                ],
534                vec![Some(5.0), Some(5.0)],
535                vec![Some(80.0), Some(160.0)],
536            ),
537        ] {
538            for (units, expected) in [(0, supplied), (1, quote)] {
539                let mut study = Volume24h::new();
540                study.set_instrument(Some(&Instrument::new("TEST").with_currency("USD")));
541                study.set_bar_clock(&BarClock::new(interval));
542                study.set_input("units", InputValue::Choice(units)).unwrap();
543                let lower = study.lower_timeframe().unwrap_or(Timeframe::Hour1);
544                let source = StudyData {
545                    request: lower_interval::request(lower),
546                    bars: &candles,
547                    clock: BarClock::new(lower),
548                    instrument: None,
549                    timeframe: lower,
550                };
551                for end in 1..=data.len() {
552                    study.update_data(&data[..end], end - 1, std::slice::from_ref(&source));
553                    assert_eq!(
554                        study.values().value(0, end - 1),
555                        expected[end - 1],
556                        "{interval:?}, units {units}, bar {}",
557                        end - 1
558                    );
559                }
560                study.update_data(&data, 0, &[source]);
561                assert_eq!(
562                    (0..data.len())
563                        .map(|i| study.values().value(0, i))
564                        .collect::<Vec<_>>(),
565                    expected
566                );
567            }
568        }
569        for (volume, close) in [
570            (2.0, f64::NAN),
571            (2.0, f64::INFINITY),
572            (f64::NAN, 10.0),
573            (-2.0, 10.0),
574            (1e308, 10.0),
575        ] {
576            for interval in [Timeframe::Hour1, five_hours, Timeframe::Week1] {
577                let mut study = Volume24h::new();
578                study.set_input("units", InputValue::Choice(1)).unwrap();
579                study.set_instrument(Some(&Instrument::new("TEST").with_currency("USD")));
580                study.set_bar_clock(&BarClock::new(interval));
581                let (data, candles, expected) = if interval == Timeframe::Hour1 {
582                    (
583                        vec![bar(0, volume, close), bar(1, 3.0, 20.0), bar(24, 4.0, 30.0)],
584                        vec![],
585                        vec![None, None, Some(180.0)],
586                    )
587                } else if interval == five_hours {
588                    (
589                        vec![bar(0, 99.0, 100.0), bar(24, 99.0, 100.0)],
590                        vec![bar(0, volume, close), bar(1, 3.0, 20.0), bar(24, 4.0, 30.0)],
591                        vec![None, Some(180.0)],
592                    )
593                } else {
594                    (
595                        vec![bar(4 * 24, 99.0, 100.0), bar(11 * 24, 99.0, 100.0)],
596                        vec![
597                            bar(7 * 24 + 16, volume, close),
598                            bar(8 * 24 + 15, 3.0, 20.0),
599                            bar(17 * 24 + 23, 4.0, 30.0),
600                        ],
601                        vec![None, Some(120.0)],
602                    )
603                };
604                let lower = study.lower_timeframe().unwrap_or(Timeframe::Hour1);
605                let source = StudyData {
606                    request: lower_interval::request(lower),
607                    bars: &candles,
608                    clock: BarClock::new(lower),
609                    instrument: None,
610                    timeframe: lower,
611                };
612                study.update_data(&data, 0, &[source]);
613                assert_eq!(
614                    (0..data.len())
615                        .map(|i| study.values().value(0, i))
616                        .collect::<Vec<_>>(),
617                    expected,
618                    "{interval:?}, {volume} × {close}"
619                );
620                study.set_instrument(None);
621                study.update_data(&data, 0, &[]);
622                assert!((0..data.len()).all(|i| study.values().value(0, i).is_none()));
623                assert_eq!(
624                    study.table().unwrap().rows[0][0],
625                    StudyCell::Text("Quote currency unavailable".into())
626                );
627            }
628        }
629    }
630
631    /// Saved settings retain the unit choice and all public readouts describe the same amount.
632    #[cfg(feature = "ui")]
633    #[test]
634    fn units_survive_templates_and_layouts_with_labelled_readouts() {
635        use crate::model::{BarData, Instrument};
636        use crate::studies::{InputValue, StudyTemplate};
637        use crate::widget::{Chart, ChartLayoutMode, SavedLayout};
638        let data = [0_i64, 1].map(|h| {
639            let (volume, close) = if h == 0 { (2.0, 10.0) } else { (3.0, 20.0) };
640            Bar::new(
641                Utc.timestamp_opt(h * 3_600, 0).unwrap(),
642                close,
643                close,
644                close,
645                close,
646                volume,
647            )
648        });
649        for (units, label, value, formatted) in [
650            (0, "Volume (supplied units)", 5.0, "5"),
651            (1, "Amount (USD)", 80.0, "80.00 USD"),
652        ] {
653            let mut study = Volume24h::new();
654            study.set_input("units", InputValue::Choice(units)).unwrap();
655            let json = serde_json::to_string(&StudyTemplate::from_study(&study)).unwrap();
656            let restored = serde_json::from_str::<StudyTemplate>(&json)
657                .unwrap()
658                .create()
659                .unwrap();
660            let mut chart = Chart::new(BarData::from_bars(data.to_vec()));
661            chart.set_timeframe(Timeframe::Hour1);
662            chart.set_instrument(Some(Instrument::new("TEST").with_currency("USD")));
663            let id = chart.add_indicator(restored);
664            let study = chart
665                .indicators()
666                .find(|(candidate, _)| *candidate == id)
667                .unwrap()
668                .1;
669            assert_eq!(study.values().value(0, 1), Some(value));
670            assert!(study.table().is_none());
671            assert_eq!(study.line_names(), [label]);
672            assert_eq!(study.format_value(value), formatted);
673            let table = chart.data_table();
674            assert_eq!(
675                table.headers.last().unwrap(),
676                &format!("24-hour Volume: {label}")
677            );
678            assert_eq!(
679                table.rows[1].last().unwrap(),
680                if units == 0 { "5.00" } else { "80.00" }
681            );
682            let saved = SavedLayout {
683                mode: ChartLayoutMode::Single,
684                charts: vec![chart.snapshot().unwrap()],
685            };
686            let saved = SavedLayout::from_json(&saved.to_json().unwrap()).unwrap();
687            let mut restored = Chart::new(BarData::from_bars(data.to_vec()));
688            restored.set_instrument(Some(Instrument::new("TEST").with_currency("USD")));
689            restored.restore(&saved.charts[0]).unwrap();
690            let study = restored.indicators().next().unwrap().1;
691            assert_eq!(
692                study
693                    .inputs()
694                    .iter()
695                    .find(|input| input.id == "units")
696                    .unwrap()
697                    .value,
698                InputValue::Choice(units)
699            );
700            assert_eq!(restored.data_table().rows[1].last(), table.rows[1].last());
701            let id = restored.indicators().next().unwrap().0;
702            let study = restored.indicator_mut(id).unwrap();
703            let mut settings = study.settings();
704            settings.precision = Some(1);
705            study.apply_settings(&[], settings).unwrap();
706            assert_eq!(
707                study.format_value(value),
708                if units == 0 { "5.0" } else { "80.0 USD" }
709            );
710            assert_eq!(
711                restored.data_table().rows[1].last().unwrap(),
712                if units == 0 { "5.0" } else { "80.0" }
713            );
714            let study = restored.indicator_mut(id).unwrap();
715            study.set_input("units", InputValue::Choice(0)).unwrap();
716            study.set_value_formatter(Some(std::sync::Arc::new(crate::scales::VolumeFormatter {
717                precision: 3,
718            })));
719            if units == 0 {
720                study.set_input("units", InputValue::Choice(1)).unwrap();
721            } else {
722                let settings = study.settings();
723                study
724                    .apply_settings(&[("units".into(), InputValue::Choice(1))], settings)
725                    .unwrap();
726            }
727            assert_eq!(study.format_value(80.0), "80.0 USD");
728        }
729    }
730
731    /// Each value is the sum of its own window, even after a volume too large for the others to
732    /// register in a running sum has left it.
733    #[test]
734    fn a_dominant_volume_leaves_no_residue() {
735        let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
736        let bars: Vec<Bar> = (0..60)
737            .map(|h| {
738                let volume = if h == 0 { 1e17 } else { 1.0 };
739                Bar::new(start + TimeDelta::hours(h), 1.0, 1.0, 1.0, 1.0, volume)
740            })
741            .collect();
742        let mut study = Volume24h::new();
743        study.calculate(&bars);
744        for bar in 24..60 {
745            assert_eq!(study.values().value(0, bar), Some(24.0), "bar {bar}");
746        }
747    }
748
749    /// On intervals longer than a day or that do not divide it, each bar sums the lower-interval
750    /// candles of the 24 hours before the close of its last candle; without candles it sums chart
751    /// bars as before. Intervals that divide the day request nothing.
752    #[test]
753    fn long_and_uneven_intervals_sum_lower_interval_candles() {
754        use crate::studies::{StudyData, StudyDataRequest};
755        let (hour, day) = (3_600, 86_400);
756        let bar =
757            |s: i64, volume| Bar::new(Utc.timestamp_opt(s, 0).unwrap(), 1.0, 1.0, 1.0, 1.0, volume);
758        let five_hours = Timeframe::Custom(std::num::NonZeroU64::new(18_000).unwrap());
759        let mut study = Volume24h::new();
760        for (chart, lower) in [
761            (Timeframe::Hour1, None),
762            (Timeframe::Day1, None),
763            (five_hours, Some(Timeframe::Min1)),
764            (Timeframe::Week1, Some(Timeframe::Hour1)),
765        ] {
766            study.set_bar_clock(&BarClock::new(chart));
767            let requested = study.data_requests().first().and_then(|r| r.timeframe);
768            assert_eq!(requested, lower, "{chart:?}");
769        }
770        let sums = |study: &mut Volume24h, data: &[Bar], lower: Timeframe, candles: &[Bar]| {
771            let source = StudyData {
772                request: StudyDataRequest {
773                    metric: None,
774                    symbol: String::new(),
775                    timeframe: Some(lower),
776                },
777                bars: candles,
778                clock: BarClock::new(lower),
779                instrument: None,
780                timeframe: lower,
781            };
782            study.update_data(data, 0, &[source]);
783            (0..data.len())
784                .map(|i| study.values().value(0, i))
785                .collect::<Vec<_>>()
786        };
787        // Weekly bars from Monday 5 January 1970 (day 4). The first week's last candle closes on
788        // Friday at 16:00, so its window starts on Thursday at 16:00; the second week's closes
789        // with the bar, on Monday 19 January at 00:00.
790        let weeks = [4, 11, 18].map(|d| bar(d * day, 1.0));
791        let hourly = [
792            bar(7 * day + 15 * hour, 1.0),   // Thursday 15:00, before the window
793            bar(7 * day + 16 * hour, 2.0),   // Thursday 16:00, its first hour
794            bar(8 * day + 15 * hour, 4.0),   // Friday 15:00, the week's last candle
795            bar(16 * day + 23 * hour, 8.0),  // Saturday 23:00, before the window
796            bar(17 * day, 16.0),             // Sunday 00:00, its first hour
797            bar(17 * day + 23 * hour, 32.0), // Sunday 23:00
798        ];
799        assert_eq!(
800            sums(&mut study, &weeks, Timeframe::Hour1, &hourly),
801            [Some(6.0), Some(48.0), None]
802        );
803        // Five-hour bars from midnight on two days; minute candles at 04:59, 05:00 and 09:59
804        // on the first day and at 04:59 and 05:00 on the second.
805        study.set_bar_clock(&BarClock::new(five_hours));
806        let bars = [0, 5, 10, 15, 20, 24, 29].map(|h| bar(h * hour, 1.0));
807        let minutes = [
808            (17_940, 1.0),
809            (18_000, 2.0),
810            (35_940, 4.0),
811            (104_340, 8.0),
812            (104_400, 16.0),
813        ]
814        .map(|(s, v)| bar(s, v));
815        assert_eq!(
816            sums(&mut study, &bars, Timeframe::Min1, &minutes),
817            [
818                Some(1.0),
819                Some(7.0),
820                None,
821                None,
822                None,
823                Some(14.0),
824                Some(28.0)
825            ]
826        );
827        // Without candles the chart bars opened in the last 24 hours are summed again.
828        assert_eq!(
829            sums(&mut study, &bars, Timeframe::Min1, &[]),
830            [1.0, 2.0, 3.0, 4.0, 5.0, 5.0, 5.0].map(Some)
831        );
832    }
833}