Skip to main content

egui_charts/data/
resample.rs

1//! Bars of a coarser interval built from bars of a finer one.
2
3use crate::model::{Bar, BarClock, Instrument, Timeframe};
4use chrono::TimeDelta;
5
6/// Builds bars of interval `to` from `bars` of interval `from`, which must be in time order.
7/// Pass timeframes for UTC boundaries, or [`BarClock`] values for a selected calendar timezone.
8/// Prices and volumes are expected to be finite, as stored series keep them.
9///
10/// Each output bar gathers the consecutive input bars that fall in one bar of
11/// the target [`BarClock`] and is stamped with that bar's open time. It takes the open
12/// of the first input bar, the highest high, the lowest low, the close of the last input bar and
13/// the summed volume. A period without input bars has no output bar. When `from == to`, the bars
14/// are returned unchanged when the clocks also share a timezone.
15///
16/// Returns `None` when `from` bars do not nest in `to` bars: weekly and monthly bars into any
17/// other interval, or an input bar that would span two output bars, as a 5-minute bar opening at
18/// 00:05 does in 7-minute bars. Also returns `None` when the bars of one slot do not combine into a
19/// valid bar, as when their volumes add up to infinity.
20///
21/// ```
22/// use chrono::{DateTime, TimeDelta, Utc};
23/// use egui_charts::data::{Bar, Timeframe, resample};
24///
25/// let start: DateTime<Utc> = "2024-03-15T10:00:00Z".parse().unwrap();
26/// let minutes: Vec<Bar> = (0..10)
27///     .map(|i| {
28///         let price = 100.0 + i as f64;
29///         Bar::new(start + TimeDelta::minutes(i), price, price + 1.0, price - 1.0, price + 0.5, 10.0)
30///     })
31///     .collect();
32///
33/// let bars = resample(&minutes, Timeframe::Min1, Timeframe::Min5).unwrap();
34/// assert_eq!(bars.len(), 2);
35/// let last = &bars[1];
36/// assert_eq!(last.time, start + TimeDelta::minutes(5));
37/// assert_eq!(
38///     (last.open, last.high, last.low, last.close, last.volume),
39///     (105.0, 110.0, 104.0, 109.5, 50.0)
40/// );
41/// ```
42pub fn resample(
43    bars: &[Bar],
44    from: impl Into<BarClock>,
45    to: impl Into<BarClock>,
46) -> Option<Vec<Bar>> {
47    let (source, target) = (from.into(), to.into());
48    let (from, to) = (source.timeframe()?, target.timeframe()?);
49    if source == target || (from == to && from.is_trade_based()) {
50        return Some(bars.to_vec());
51    }
52    if from.is_trade_based() || to.is_trade_based() {
53        return None;
54    }
55    // Weeks do not nest in months or in fixed multi-week intervals, and a month has no fixed length.
56    if matches!(
57        from,
58        Timeframe::Week1 | Timeframe::Month1 | Timeframe::Weeks(_) | Timeframe::Months(_)
59    ) {
60        return None;
61    }
62    let mut resampled: Vec<Bar> = Vec::new();
63    for bar in bars {
64        let open = source.bar_open(bar.time);
65        // The next open rather than `open + from`: the last bar of a day is short when `from`
66        // does not divide a day.
67        let end = source.next_open(open)?;
68        let slot = target.bar_open(open);
69        if target.bar_open(end - TimeDelta::milliseconds(1)) != slot {
70            return None;
71        }
72        match resampled.last_mut() {
73            Some(last) if last.time == slot => *last = super::merge::fold(last, bar)?,
74            // The colours and payload of a source bar are not those of the bar it falls in.
75            _ => resampled.push(Bar {
76                time: slot,
77                colors: None,
78                payload: None,
79                ..bar.clone()
80            }),
81        }
82    }
83    Some(resampled)
84}
85
86/// Builds a coarser interval using the instrument's exchange trading hours and dates.
87///
88/// Intraday inputs must be stamped at their actual open, including a shortened final bar.
89/// Intraday target slots start at the first selected session window of each trading date;
90/// daily, weekly and monthly targets use that trading date, including overnight sessions.
91/// Bars outside the selected hours and holidays are omitted. No bars are invented across gaps,
92/// and the last output includes all available inputs even while its period is forming.
93/// Daily and longer inputs already carry their trading date as a calendar label. Pass timeframes
94/// for UTC labels, or matching source and target [`BarClock`] values to retain another calendar.
95/// Exchange time determines the trading date; the target clock determines its output label.
96///
97/// As with [`resample`], returns `None` if an input straddles target slots or folded volume
98/// overflows. The host retains the base history and calls again after live updates or corrections.
99/// Native source intervals can be reported through [`Instrument::native_timeframes`].
100pub fn resample_with_session(
101    bars: &[Bar],
102    from: impl Into<BarClock>,
103    to: impl Into<BarClock>,
104    instrument: &Instrument,
105    extended_hours: bool,
106) -> Option<Vec<Bar>> {
107    Some(
108        resample_periods(bars, from, to, instrument, extended_hours)?
109            .into_iter()
110            .map(|(_, bar)| bar)
111            .collect(),
112    )
113}
114
115pub(crate) fn resample_periods(
116    bars: &[Bar],
117    from: impl Into<BarClock>,
118    to: impl Into<BarClock>,
119    instrument: &Instrument,
120    extended_hours: bool,
121) -> Option<Vec<(std::ops::Range<usize>, Bar)>> {
122    use crate::model::{ExchangeSessionProvider, TimezoneId};
123
124    let zone = instrument.time_zone.clone().unwrap_or_else(TimezoneId::utc);
125    let (source, target) = (from.into(), to.into());
126    let (from, to) = (source.timeframe()?, target.timeframe()?);
127    if (instrument.session.is_none()
128        && (instrument.time_zone.is_none() || to.duration_ms() < Timeframe::Day1.duration_ms()))
129        || from.duration_ms() >= Timeframe::Day1.duration_ms()
130        || from.is_trade_based()
131        || to.is_trade_based()
132    {
133        let output = resample(bars, source.clone(), target.clone())?;
134        let mut start = 0;
135        return Some(
136            output
137                .into_iter()
138                .map(|bar| {
139                    let end = start
140                        + bars[start..].partition_point(|input| {
141                            target.bar_open(source.bar_open(input.time)) == bar.time
142                        });
143                    let range = start..end;
144                    start = end;
145                    (range, bar)
146                })
147                .collect(),
148        );
149    }
150    let mut session = instrument.session.clone();
151    if !extended_hours && let Some(session) = &mut session {
152        // Keep an empty regular schedule distinct from an explicitly continuous session.
153        if session.windows.is_empty() && !session.extended_windows.is_empty() {
154            return Some(Vec::new());
155        }
156        session.clear_extended_windows();
157    }
158    let provider = ExchangeSessionProvider::new(zone, session);
159    let opens = provider.opens(bars.iter().map(|bar| bar.time));
160    let slot = |time| {
161        let n = opens.partition_point(|(open, _)| *open <= time);
162        let (open, date) = *opens.get(n.checked_sub(1)?)?;
163        if to.duration_ms() < Timeframe::Day1.duration_ms() {
164            let elapsed = (time - open).num_milliseconds();
165            open.checked_add_signed(TimeDelta::milliseconds(
166                elapsed - elapsed.rem_euclid(to.duration_ms()),
167            ))
168        } else {
169            target.open_on_date(date)
170        }
171    };
172    let mut output: Vec<(std::ops::Range<usize>, Bar)> = Vec::new();
173    for (i, bar) in bars.iter().enumerate() {
174        if !provider.is_open(bar.time, true) {
175            continue;
176        }
177        let period = slot(bar.time)?;
178        let mut end = if instrument.session.is_none() {
179            source.next_open(source.bar_open(bar.time))?
180        } else {
181            bar.time.checked_add_signed(from.duration())?
182        };
183        if let Some(close) = provider.session_close(bar.time, true) {
184            end = end.min(close);
185        }
186        if slot(end.checked_sub_signed(TimeDelta::nanoseconds(1))?)? != period {
187            return None;
188        }
189        match output.last_mut() {
190            Some((range, last)) if last.time == period => {
191                *last = super::merge::fold(last, bar)?;
192                range.end = i + 1;
193            }
194            _ => output.push((
195                i..i + 1,
196                Bar {
197                    time: period,
198                    colors: None,
199                    payload: None,
200                    ..bar.clone()
201                },
202            )),
203        }
204    }
205    Some(output)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use chrono::{DateTime, Utc};
212
213    fn bar(time: &str, [open, high, low, close, volume]: [f64; 5]) -> Bar {
214        let time: DateTime<Utc> = time.parse().unwrap();
215        Bar::new(time, open, high, low, close, volume)
216    }
217
218    #[cfg(feature = "timezones")]
219    #[test]
220    fn resamples_selected_exchange_sessions() {
221        use crate::model::{Session, SessionWindow, TimezoneId};
222        use chrono::{NaiveTime, Weekday};
223        let clock = |h, m| NaiveTime::from_hms_opt(h, m, 0).unwrap();
224        let instrument = Instrument::new("SESSION")
225            .with_time_zone(TimezoneId::named("America/New_York").unwrap())
226            .with_session(
227                Session::new([SessionWindow::new(
228                    [Weekday::Fri, Weekday::Mon],
229                    clock(9, 30),
230                    clock(16, 0),
231                )])
232                .with_extended_windows([SessionWindow::new(
233                    [Weekday::Fri, Weekday::Mon],
234                    clock(8, 30),
235                    clock(9, 30),
236                )])
237                .with_holidays(["2024-03-18".parse().unwrap()]),
238            );
239        let input = vec![
240            bar("2024-03-15T12:30:00Z", [9.0, 10.0, 8.0, 9.0, 1.0]),
241            bar("2024-03-15T13:30:00Z", [10.0, 13.0, 9.0, 12.0, 2.0]),
242            bar("2024-03-15T14:30:00Z", [12.0, 15.0, 11.0, 14.0, 3.0]),
243            bar("2024-03-15T15:30:00Z", [14.0, 16.0, 10.0, 11.0, 4.0]),
244            bar("2024-03-15T19:30:00Z", [11.0, 12.0, 7.0, 8.0, 5.0]),
245            bar("2024-03-18T13:30:00Z", [8.0, 9.0, 6.0, 7.0, 6.0]),
246        ];
247        for (extended, expected) in [
248            (
249                false,
250                vec![
251                    bar("2024-03-15T13:30:00Z", [10.0, 15.0, 9.0, 14.0, 5.0]),
252                    bar("2024-03-15T15:30:00Z", [14.0, 16.0, 10.0, 11.0, 4.0]),
253                    bar("2024-03-15T19:30:00Z", [11.0, 12.0, 7.0, 8.0, 5.0]),
254                ],
255            ),
256            (
257                true,
258                vec![
259                    bar("2024-03-15T12:30:00Z", [9.0, 13.0, 8.0, 12.0, 3.0]),
260                    bar("2024-03-15T14:30:00Z", [12.0, 16.0, 10.0, 11.0, 7.0]),
261                    bar("2024-03-15T18:30:00Z", [11.0, 12.0, 7.0, 8.0, 5.0]),
262                ],
263            ),
264        ] {
265            assert_eq!(
266                resample_with_session(
267                    &input,
268                    Timeframe::Hour1,
269                    "2h".parse::<Timeframe>().unwrap(),
270                    &instrument,
271                    extended
272                ),
273                Some(expected)
274            );
275        }
276        let continuous = Instrument::new("CONTINUOUS")
277            .with_time_zone(TimezoneId::utc())
278            .with_session(Session::new([]));
279        let last = bar("2024-03-15T23:55:00Z", [10.0, 13.0, 9.0, 12.0, 2.0]);
280        assert_eq!(
281            resample_with_session(
282                &[last],
283                "7min".parse::<Timeframe>().unwrap(),
284                Timeframe::Day1,
285                &continuous,
286                false,
287            ),
288            Some(vec![bar(
289                "2024-03-15T00:00:00Z",
290                [10.0, 13.0, 9.0, 12.0, 2.0]
291            )])
292        );
293        #[cfg(feature = "widget")]
294        {
295            let mut chart = crate::widget::ChartBuilder::new().build();
296            chart.set_timeframe("2h".parse().unwrap());
297            chart.set_instrument(Some(instrument.clone()));
298            assert_eq!(chart.bar_clock().bar_open(input[1].time), input[1].time);
299            chart.set_extended_hours(true);
300            assert_eq!(chart.bar_clock().bar_open(input[1].time), input[0].time);
301        }
302        let overnight = Instrument::new("OVERNIGHT")
303            .with_time_zone(TimezoneId::named("America/New_York").unwrap())
304            .with_session(Session::new([SessionWindow::new(
305                [Weekday::Mon, Weekday::Tue],
306                clock(18, 0),
307                clock(17, 0),
308            )]));
309        let input = vec![
310            bar("2024-03-10T22:00:00Z", [10.0, 13.0, 9.0, 12.0, 2.0]),
311            bar("2024-03-11T03:00:00Z", [12.0, 15.0, 11.0, 14.0, 3.0]),
312            bar("2024-03-11T20:00:00Z", [14.0, 16.0, 10.0, 11.0, 4.0]),
313            bar("2024-03-11T21:00:00Z", [99.0, 99.0, 99.0, 99.0, 1.0]),
314            bar("2024-03-11T22:00:00Z", [11.0, 12.0, 7.0, 8.0, 5.0]),
315        ];
316        assert_eq!(
317            resample_with_session(&input, Timeframe::Hour1, Timeframe::Day1, &overnight, false),
318            Some(vec![
319                bar("2024-03-11T00:00:00Z", [10.0, 16.0, 9.0, 11.0, 9.0]),
320                bar("2024-03-12T00:00:00Z", [11.0, 12.0, 7.0, 8.0, 5.0]),
321            ])
322        );
323        // Date-labelled daily inputs must not be read as instants on the preceding New York day.
324        let monday = vec![bar("2024-03-11T00:00:00Z", [10.0, 13.0, 9.0, 12.0, 2.0])];
325        assert_eq!(
326            resample_with_session(
327                &monday,
328                Timeframe::Day1,
329                Timeframe::Week1,
330                &overnight,
331                false
332            ),
333            Some(monday.clone())
334        );
335        // Tokyo midnight is on the preceding UTC date; its trading date still labels March 11.
336        let tokyo =
337            Instrument::new("TOKYO").with_time_zone(TimezoneId::named("Asia/Tokyo").unwrap());
338        assert_eq!(
339            resample_with_session(&monday, Timeframe::Hour1, Timeframe::Day1, &tokyo, false),
340            Some(monday)
341        );
342        let local =
343            BarClock::new(Timeframe::Day1).with_timezone("America/New_York".parse().unwrap());
344        let selected =
345            resample_with_session(&input, Timeframe::Hour1, local, &overnight, false).unwrap();
346        assert_eq!(
347            selected.iter().map(|bar| bar.time).collect::<Vec<_>>(),
348            [
349                "2024-03-11T04:00:00Z".parse::<DateTime<Utc>>().unwrap(),
350                "2024-03-12T04:00:00Z".parse::<DateTime<Utc>>().unwrap(),
351            ]
352        );
353    }
354
355    /// Guards the OHLCV aggregation, the alignment of output bars to `BarClock`, and the refusal of
356    /// intervals whose bars would be split across two output bars.
357    #[test]
358    fn resamples_into_bar_clock_slots() {
359        let min7: Timeframe = "7min".parse().unwrap();
360        #[rustfmt::skip]
361        let cases = [
362            // The seven-minute bar that opens at 23:55 is short: the next one opens at midnight.
363            (Timeframe::Min1, min7, vec![
364                bar("2024-03-15T23:54:00Z", [10.0, 12.0, 9.0, 11.0, 1.0]),
365                bar("2024-03-15T23:55:00Z", [11.0, 13.0, 10.0, 12.0, 2.0]),
366                bar("2024-03-15T23:56:00Z", [12.0, 15.0, 11.0, 14.0, 3.0]),
367                bar("2024-03-15T23:57:00Z", [14.0, 14.0, 8.0, 9.0, 4.0]),
368                bar("2024-03-15T23:58:00Z", [9.0, 11.0, 9.0, 10.0, 5.0]),
369                bar("2024-03-15T23:59:00Z", [10.0, 12.0, 10.0, 11.0, 6.0]),
370                bar("2024-03-16T00:00:00Z", [11.0, 11.0, 10.0, 10.0, 7.0]),
371                bar("2024-03-16T00:01:00Z", [10.0, 13.0, 10.0, 12.0, 8.0]),
372            ], Some(vec![
373                bar("2024-03-15T23:48:00Z", [10.0, 12.0, 9.0, 11.0, 1.0]),
374                bar("2024-03-15T23:55:00Z", [11.0, 15.0, 8.0, 11.0, 20.0]),
375                bar("2024-03-16T00:00:00Z", [11.0, 13.0, 10.0, 12.0, 15.0]),
376            ])),
377            // The short 23:55 bar ends at midnight, so it fits in the day.
378            (min7, Timeframe::Day1, vec![
379                bar("2024-03-15T23:48:00Z", [10.0, 12.0, 9.0, 11.0, 1.0]),
380                bar("2024-03-15T23:55:00Z", [11.0, 15.0, 8.0, 11.0, 20.0]),
381                bar("2024-03-16T00:00:00Z", [11.0, 13.0, 10.0, 12.0, 15.0]),
382            ], Some(vec![
383                bar("2024-03-15T00:00:00Z", [10.0, 15.0, 8.0, 11.0, 21.0]),
384                bar("2024-03-16T00:00:00Z", [11.0, 13.0, 10.0, 12.0, 15.0]),
385            ])),
386            // The 00:05 bar runs to 00:10, across the 00:07 boundary.
387            (Timeframe::Min5, min7, vec![
388                bar("2024-03-15T00:00:00Z", [1.0, 1.0, 1.0, 1.0, 1.0]),
389                bar("2024-03-15T00:05:00Z", [1.0, 1.0, 1.0, 1.0, 1.0]),
390            ], None),
391            // The 02:00 bar is missing: the 00:00 bar is built from the three present.
392            (Timeframe::Hour1, Timeframe::Hour4, vec![
393                bar("2024-03-15T00:00:00Z", [20.0, 22.0, 19.0, 21.0, 100.0]),
394                bar("2024-03-15T01:00:00Z", [21.0, 25.0, 20.0, 24.0, 200.0]),
395                bar("2024-03-15T03:00:00Z", [24.0, 24.0, 18.0, 19.0, 300.0]),
396                bar("2024-03-15T04:00:00Z", [19.0, 20.0, 17.0, 18.0, 400.0]),
397            ], Some(vec![
398                bar("2024-03-15T00:00:00Z", [20.0, 25.0, 18.0, 19.0, 600.0]),
399                bar("2024-03-15T04:00:00Z", [19.0, 20.0, 17.0, 18.0, 400.0]),
400            ])),
401            // Saturday and Sunday belong to the week that opened on Monday the 11th.
402            (Timeframe::Day1, Timeframe::Week1, vec![
403                bar("2024-03-16T00:00:00Z", [30.0, 31.0, 29.0, 30.0, 1.0]),
404                bar("2024-03-17T00:00:00Z", [30.0, 33.0, 30.0, 32.0, 2.0]),
405                bar("2024-03-18T00:00:00Z", [32.0, 34.0, 31.0, 33.0, 3.0]),
406                bar("2024-03-19T00:00:00Z", [33.0, 33.0, 28.0, 29.0, 4.0]),
407            ], Some(vec![
408                bar("2024-03-11T00:00:00Z", [30.0, 33.0, 29.0, 32.0, 3.0]),
409                bar("2024-03-18T00:00:00Z", [32.0, 34.0, 28.0, 29.0, 7.0]),
410            ])),
411            // Two volumes that add up to infinity give no valid bar.
412            (Timeframe::Min1, Timeframe::Min5, vec![
413                bar("2024-03-15T00:00:00Z", [1.0, 1.0, 1.0, 1.0, f64::MAX]),
414                bar("2024-03-15T00:01:00Z", [1.0, 1.0, 1.0, 1.0, f64::MAX]),
415            ], None),
416            // Some weeks end in the next month, so weekly bars never build monthly ones, even a week inside one month.
417            (Timeframe::Week1, Timeframe::Month1, vec![
418                bar("2024-03-04T00:00:00Z", [1.0, 1.0, 1.0, 1.0, 1.0]),
419            ], None),
420            ("2w".parse::<Timeframe>().unwrap(), "3month".parse::<Timeframe>().unwrap(), vec![
421                bar("2024-03-04T00:00:00Z", [1.0, 1.0, 1.0, 1.0, 1.0]),
422            ], None),
423            ("3month".parse::<Timeframe>().unwrap(), "6month".parse::<Timeframe>().unwrap(), vec![
424                bar("2024-01-01T00:00:00Z", [1.0, 1.0, 1.0, 1.0, 1.0]),
425            ], None),
426        ];
427        for (from, to, bars, expected) in cases {
428            assert_eq!(resample(&bars, from, to), expected, "{from} to {to}");
429        }
430        let Some(zone) = crate::model::TimezoneId::named("America/New_York") else {
431            return;
432        };
433        let local = BarClock::new(Timeframe::Hour4).with_timezone(zone);
434        // Local midnight to 04:00 lasts three hours on the spring clock change.
435        let input = vec![
436            bar("2024-03-10T05:00:00Z", [100.0, 101.0, 99.0, 100.0, 2.0]),
437            bar("2024-03-10T07:59:00Z", [102.0, 104.0, 101.0, 103.0, 3.0]),
438            bar("2024-03-10T08:00:00Z", [103.0, 105.0, 102.0, 104.0, 4.0]),
439        ];
440        assert_eq!(
441            resample(&input, Timeframe::Min1, local),
442            Some(vec![
443                bar("2024-03-10T05:00:00Z", [100.0, 104.0, 99.0, 103.0, 5.0]),
444                bar("2024-03-10T08:00:00Z", [103.0, 105.0, 102.0, 104.0, 4.0]),
445            ])
446        );
447    }
448}