Skip to main content

egui_charts/widget/
replay.rs

1//! Retained history and a shared, exclusive replay cutoff.
2
3use super::series::PriceSeries;
4use super::{Chart, ChartLayout, SeriesId};
5use crate::data::ReplayDriver;
6use crate::data::merge::{Origin, fold, merge};
7use crate::model::{Bar, BarClock, Timeframe};
8use chrono::{DateTime, Utc};
9use std::collections::{BTreeMap, HashMap};
10use std::sync::Arc;
11
12pub(super) struct ReplayWindow {
13    pub(super) time: DateTime<Utc>,
14    pub(super) bars: HashMap<SeriesId, Vec<Bar>>,
15    source: Option<(Vec<Bar>, BarClock)>,
16    /// The pause last set with `set_feed_paused` before the replay, which its exit sets again.
17    feed_paused: bool,
18    pub quote: Option<crate::model::Level1Quote>,
19    pub session_quote: Option<crate::model::quote::SessionQuote>,
20}
21
22pub(super) struct MarketReplay {
23    driver: Arc<ReplayDriver>,
24    feed: Option<super::feed::FeedLink>,
25    window: ReplayWindow,
26}
27
28impl ReplayWindow {
29    pub(super) fn remove_oldest_bars(
30        &mut self,
31        id: SeriesId,
32        count: usize,
33        clock: &BarClock,
34    ) -> Vec<Bar> {
35        let bars = self.bars.entry(id).or_default();
36        let count = count.min(bars.len());
37        if id == SeriesId::MAIN
38            && count > 0
39            && let Some((source, _)) = &mut self.source
40        {
41            let through = clock.bar_open(bars[count - 1].time);
42            let end = source.partition_point(|bar| clock.bar_open(bar.time) <= through);
43            source.drain(..end);
44            super::series::shrink_bars(source);
45        }
46        let removed = bars.drain(..count).collect();
47        super::series::shrink_bars(bars);
48        removed
49    }
50}
51
52impl Chart {
53    /// The exclusive replay cutoff, or `None` when showing live data.
54    pub fn replay_time(&self) -> Option<DateTime<Utc>> {
55        self.market_replay
56            .as_ref()
57            .map(|replay| {
58                replay
59                    .driver
60                    .time()
61                    .checked_add_signed(chrono::TimeDelta::nanoseconds(1))
62                    .unwrap_or(chrono::DateTime::<Utc>::MAX_UTC)
63            })
64            .or_else(|| self.replay.as_ref().map(|replay| replay.time))
65    }
66
67    /// The shared event driver attached with [`Self::set_market_replay`], if any.
68    pub fn replay_driver(&self) -> Option<&Arc<ReplayDriver>> {
69        self.market_replay.as_ref().map(|replay| &replay.driver)
70    }
71
72    /// Replaces the live feed with a shared recording-backed driver, or restores it with `None`.
73    /// Retains the live bars, quotes, pause state and feed configuration for exit. Recorded trades
74    /// build partial bars through the normal feed path, alongside quotes, orders and depth.
75    /// Attach the same driver to every participating chart, then advance it once per frame before
76    /// drawing. Its time is inclusive; [`Self::replay_time`] reports the exclusive cutoff one
77    /// nanosecond later. An interval change resubscribes at the same shared position.
78    pub fn set_market_replay(&mut self, driver: Option<Arc<ReplayDriver>>) {
79        if let Some(replay) = self.market_replay.take() {
80            self.feed_link = replay.feed;
81            self.replay = Some(replay.window);
82            self.set_replay_time(None);
83        }
84        let Some(driver) = driver else { return };
85        let history = self
86            .feed_link
87            .as_ref()
88            .and_then(|link| link.depth_history.as_ref())
89            .map(|history| {
90                crate::data::DepthHistory::new(history.config().clone())
91                    .expect("existing depth history configuration")
92            });
93        let view = self.order_book_view();
94        let heatmap_config = self.liquidity_heatmap_config();
95        self.set_replay_time(Some(driver.time()));
96        let window = self.replay.take().expect("replay window");
97        let feed = self.feed_link.take();
98        self.attach_feed(driver.clone(), 1);
99        self.set_liquidity_heatmap_config(heatmap_config);
100        if let (Some(live), Some(replay)) = (&feed, &mut self.feed_link) {
101            replay.heatmap = live.heatmap.as_ref().map(|_| Default::default());
102            replay.book_visible = live.book_visible;
103            replay.bubbles_config = live.bubbles_config.clone();
104        }
105        self.set_volume_bubbles(
106            feed.as_ref()
107                .and_then(|link| link.bubbles.as_ref())
108                .map(|bubbles| bubbles.config.clone()),
109        );
110        self.set_depth_history(history);
111        self.set_order_book_view(view);
112        self.replace_main_data(Vec::new());
113        self.market_replay = Some(MarketReplay {
114            driver,
115            feed,
116            window,
117        });
118    }
119
120    /// Enters or seeks replay, retaining the full history inside the chart. Only bars completed
121    /// by `time` reach rendering, studies, auto-scaling and readouts, including the histories
122    /// that studies request for other symbols and intervals. A bar's close is its
123    /// `BarClock::next_open`; without a timeframe, timestamps are observation instants and bars
124    /// strictly before `time` are available. Supply finer bars with `set_replay_source` to build
125    /// the unfinished chart bar. No future OHLC is inferred from a completed coarse bar.
126    ///
127    /// The main feed is suspended and current quotes and book views are hidden. Use
128    /// [`Self::set_market_replay`] to replay recorded trades, quotes and depth together.
129    /// `set_data` and `update` retain
130    /// host corrections behind the window. `None` exits, restores all retained bars and quotes,
131    /// and restores the feed's previous pause state. Hosts own source data and session storage.
132    /// With a market driver attached this dispatches a seek; call outside egui context locks,
133    /// as with [`ReplayDriver::seek`].
134    pub fn set_replay_time(&mut self, time: Option<DateTime<Utc>>) {
135        if let Some(replay) = &self.market_replay {
136            if let Some(time) = time {
137                let inclusive = time
138                    .checked_sub_signed(chrono::TimeDelta::nanoseconds(1))
139                    .unwrap_or(DateTime::<Utc>::MIN_UTC);
140                if replay.driver.time() != inclusive {
141                    replay.driver.seek(inclusive);
142                }
143            } else {
144                self.set_market_replay(None);
145            }
146            return;
147        }
148        let Some(time) = time else {
149            if let Some(replay) = self.replay.take() {
150                for (id, bars) in replay.bars {
151                    if id == SeriesId::MAIN {
152                        self.replace_main_data(bars);
153                    } else if let Some(item) = self.price_series.iter_mut().find(|s| s.id == id) {
154                        item.bars = bars;
155                        item.revision = crate::model::next_revision();
156                    }
157                }
158                self.quote = replay.quote;
159                self.session_quote = replay.session_quote;
160                self.set_feed_paused(replay.feed_paused);
161                self.invalidate_study_data();
162                self.replay_order_flow(replay.time);
163            }
164            return;
165        };
166        let previous = self.replay.as_ref().map(|replay| replay.time);
167        let rewind = self.replay.as_ref().is_none_or(|replay| time < replay.time);
168        if let Some(replay) = &mut self.replay {
169            if replay.time == time {
170                return;
171            }
172            replay.time = time;
173        } else {
174            let mut bars: HashMap<_, _> = self
175                .price_series
176                .iter()
177                .map(|s| (s.id, s.bars.clone()))
178                .collect();
179            bars.insert(SeriesId::MAIN, self.state.data().bars.clone());
180            let feed_paused = self.feed_link.as_ref().is_some_and(|link| link.host_paused);
181            self.replay = Some(ReplayWindow {
182                time,
183                bars,
184                source: None,
185                feed_paused,
186                quote: self.quote.take(),
187                session_quote: self.session_quote.take(),
188            });
189            self.set_feed_paused(true);
190        }
191        self.apply_replay_window();
192        self.replay_order_flow(previous.map_or(time, |previous| previous.min(time)));
193        // Studies on other histories see the bars completed by the new cursor.
194        self.invalidate_study_data();
195        if rewind {
196            // A cached value at the cursor may have used the candle's later high or close.
197            for study in self.studies.indicators_mut() {
198                study.update(&[], 0);
199            }
200            self.pending_from.fill(Some(0));
201            for study in self.element_studies.studies.indicators_mut() {
202                study.update(&[], 0);
203            }
204            self.element_studies.pending_from.fill(Some(0));
205        }
206    }
207
208    /// Supplies authoritative finer history for the main series during replay. Its completed
209    /// bars build the partial chart bar using first open, maximum high, minimum low, last close
210    /// and summed volume. Earlier chart history stays available. The host must supply all finer
211    /// bars in the covered chart slots; missing bars are treated as periods without trades.
212    ///
213    /// Recorded trades replay one at a time as bars of one trade each under a clock of
214    /// `Timeframe::Ticks` one, as [`data::aggregate_trades`](crate::data::aggregate_trades) builds
215    /// them. A trade is an instant: it falls in the chart bar that holds it and is complete once
216    /// the cutoff passes it, so a `ReplayController` over the same bars advances the unfinished bar
217    /// trade by trade. `aggregate_trades` stamps trades that share a timestamp one nanosecond
218    /// apart, keeping each a step of its own; bars at one time would replace each other. Bars of
219    /// more than one trade are refused, since each is stamped at its first trade.
220    ///
221    /// Returns `false` without changes outside replay, without a chart timeframe, for invalid
222    /// bars, or when the source intervals do not nest in the chart interval. Input is cleaned
223    /// and sorted as by `set_data`. An empty source clears it. Other bar series use completed
224    /// chart bars. Change playback's cursor interval to the supplied source interval in the host.
225    pub fn set_replay_source(&mut self, bars: Vec<Bar>, clock: BarClock) -> bool {
226        if self.replay.is_none() || self.timeframe.is_none() {
227            return false;
228        }
229        let (bars, rejected) = super::series::clean(bars, &clock);
230        if rejected != 0 || fold_source(&bars, &clock, &self.bar_clock()).is_none() {
231            return false;
232        }
233        if let Some(replay) = &mut self.replay {
234            replay.source = (!bars.is_empty()).then_some((bars, clock));
235        }
236        self.apply_replay_window();
237        true
238    }
239
240    /// Applies the replay cutoff, or its end, to the order-flow inputs of the studies, which the
241    /// paused feed no longer updates, and recalculates those studies from the bar containing
242    /// `since`, the earlier of the old and the new cutoff.
243    fn replay_order_flow(&mut self, since: DateTime<Utc>) {
244        let time = self.replay_time();
245        let link = self.feed_link.as_ref();
246        let clock = link.map_or_else(|| self.bar_clock(), |link| link.clock.clone());
247        let trades = link.map(|link| &link.trades);
248        let open = clock.bar_open(since);
249        let from = (self.state.data().bars).partition_point(|bar| clock.bar_open(bar.time) < open);
250        for (at, study) in self.studies.indicators().iter().enumerate() {
251            let inputs = study.order_flow_inputs();
252            if inputs.is_empty() {
253                continue;
254            }
255            for input in inputs {
256                input.set_replay(&clock, time, trades);
257            }
258            self.pending_from[at] = Some(self.pending_from[at].map_or(from, |old| old.min(from)));
259        }
260    }
261
262    pub(super) fn apply_replay_window(&mut self) {
263        let Some(mut replay) = self.replay.take() else {
264            return;
265        };
266        let clock = self
267            .timeframe
268            .map_or_else(BarClock::exact, |_| self.bar_clock());
269        for (&id, &limit) in &self.bar_limits {
270            let count = replay
271                .bars
272                .get(&id)
273                .map_or(0, |bars| super::series::evicted_bars(bars.len(), limit));
274            if count > 0 {
275                replay.remove_oldest_bars(id, count, &clock);
276                if id == SeriesId::MAIN {
277                    self.feed.history_complete = false;
278                }
279            }
280        }
281        if let Some(bars) = replay.bars.get(&SeriesId::MAIN) {
282            let mut visible = completed(bars, &clock, replay.time).to_vec();
283            if let Some((source, source_clock)) = &replay.source
284                && let Some(first) = source.first()
285                && let Some(last) = source.last()
286                && let Some(partial) = fold_source(
287                    completed(source, source_clock, replay.time),
288                    source_clock,
289                    &clock,
290                )
291            {
292                let start = clock.bar_open(first.time);
293                let end = clock.bar_open(last.time);
294                visible.retain(|bar| {
295                    let slot = clock.bar_open(bar.time);
296                    slot < start || slot > end
297                });
298                merge(
299                    &mut visible,
300                    &mut BTreeMap::new(),
301                    &clock,
302                    partial.into_iter().map(|bar| (bar, Origin::Live)).collect(),
303                );
304            }
305            self.replace_main_data(visible);
306        }
307        for item in &mut self.price_series {
308            if let Some(bars) = replay.bars.get(&item.id) {
309                item.bars = completed(bars, &clock, replay.time).to_vec();
310                item.revision = crate::model::next_revision();
311            }
312        }
313        self.replay = Some(replay);
314    }
315
316    pub(super) fn replay_set_data(&mut self, id: SeriesId, bars: Vec<Bar>) {
317        let clock = self.clock();
318        let Some(replay) = &mut self.replay else {
319            return;
320        };
321        let (bars, rejected) = super::series::clean(bars, &clock);
322        replay.bars.insert(id, bars);
323        self.count_replay_rejections(id, rejected);
324        self.apply_replay_window();
325    }
326
327    /// Merges `bar` into the retained bars of `id`; returns whether they changed.
328    pub(super) fn replay_update(&mut self, id: SeriesId, bar: Bar) -> bool {
329        let clock = self.clock();
330        let Some(replay) = &mut self.replay else {
331            return false;
332        };
333        let merged = merge(
334            replay.bars.entry(id).or_default(),
335            &mut BTreeMap::new(),
336            &clock,
337            vec![(bar, Origin::Live)],
338        );
339        self.count_replay_rejections(id, merged.rejected);
340        self.apply_replay_window();
341        merged.first_changed.is_some()
342    }
343
344    /// Removes the last `count` retained bars of `id` and returns them.
345    pub(super) fn replay_pop(&mut self, id: SeriesId, count: usize) -> Vec<Bar> {
346        let Some(replay) = &mut self.replay else {
347            return Vec::new();
348        };
349        let bars = replay.bars.entry(id).or_default();
350        let removed = bars.split_off(bars.len().saturating_sub(count));
351        self.apply_replay_window();
352        removed
353    }
354
355    /// Gives `series`, which has left the chart, its retained bars. The window only holds series
356    /// in the chart, so the history leaves with the series and is dropped with it.
357    pub(super) fn replay_release(&mut self, series: &mut PriceSeries) {
358        if let Some(bars) = self
359            .replay
360            .as_mut()
361            .and_then(|replay| replay.bars.remove(&series.id))
362        {
363            series.bars = bars;
364        }
365    }
366
367    /// Retains the bars of `series` as it enters the chart, leaving it those the cutoff completed.
368    pub(super) fn replay_admit(&mut self, series: &mut PriceSeries) {
369        let clock = self
370            .timeframe
371            .map_or_else(BarClock::exact, |_| self.bar_clock());
372        if let Some(replay) = &mut self.replay {
373            let bars = std::mem::take(&mut series.bars);
374            series.bars = completed(&bars, &clock, replay.time).to_vec();
375            replay.bars.insert(series.id, bars);
376        }
377    }
378
379    fn count_replay_rejections(&mut self, id: SeriesId, rejected: u64) {
380        if id == SeriesId::MAIN {
381            self.feed.rejected_bars += rejected;
382        } else if let Some(item) = self.price_series.iter_mut().find(|s| s.id == id) {
383            item.feed.rejected_bars += rejected;
384        }
385    }
386}
387
388pub(super) fn completed<'a>(bars: &'a [Bar], clock: &BarClock, time: DateTime<Utc>) -> &'a [Bar] {
389    let end = bars.partition_point(|bar| {
390        clock
391            .next_open(clock.bar_open(bar.time))
392            .map_or(bar.time < time, |end| end <= time)
393    });
394    &bars[..end]
395}
396
397/// The chart bars that `bars` of the `source` clock build under `chart`, or `None` when they do
398/// not nest in them. A bar of one trade is an instant and falls in the chart bar that holds it;
399/// other sources go through `data::resample`, which refuses the other trade-based intervals,
400/// whose bars are stamped at their first trade. Replay folds the completed trades on every step,
401/// so they are folded per chart bar, bisecting for its last trade, instead of a clock lookup per
402/// trade: a session clock's lookup is not cheap. `bar_open` is ordered, as `completed` assumes.
403fn fold_source(bars: &[Bar], source: &BarClock, chart: &BarClock) -> Option<Vec<Bar>> {
404    let instants = matches!(source.timeframe(), Some(Timeframe::Ticks(count)) if count.get() == 1)
405        && chart
406            .timeframe()
407            .is_some_and(|timeframe| !timeframe.is_trade_based());
408    if !instants {
409        return crate::data::resample(bars, source.clone(), chart.clone());
410    }
411    let mut folded = Vec::new();
412    let mut rest = bars;
413    while let Some(first) = rest.first() {
414        let slot = chart.bar_open(first.time);
415        // The first trade is in its own slot, so every pass takes at least one.
416        let end = rest
417            .partition_point(|bar| chart.bar_open(bar.time) <= slot)
418            .max(1);
419        // As in `resample`: a source bar's colours and payload are not the chart bar's.
420        let mut bar = Bar {
421            time: slot,
422            colors: None,
423            payload: None,
424            ..first.clone()
425        };
426        for trade in &rest[1..end] {
427            bar = fold(&bar, trade)?;
428        }
429        folded.push(bar);
430        rest = &rest[end..];
431    }
432    // shortcut: every step refolds all trades before the cutoff; keep the folded complete chart
433    // bars if long trade sources make playback slow.
434    Some(folded)
435}
436
437impl ChartLayout {
438    /// Sets one replay cutoff on the source slot's synchronization group, including hidden
439    /// slots. Other groups keep their own sessions. Call for every cursor move and with `None`
440    /// to exit; each chart windows its own host-supplied history at its own interval.
441    pub fn set_replay_time(
442        &self,
443        charts: &mut [&mut Chart],
444        source: usize,
445        time: Option<DateTime<Utc>>,
446    ) {
447        if source >= charts.len().min(16) {
448            return;
449        }
450        for (index, chart) in charts.iter_mut().take(16).enumerate() {
451            if self.sync_group(index) == self.sync_group(source) {
452                chart.set_replay_time(time);
453            }
454        }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::model::{BarData, Timeframe};
462
463    fn bar(minute: i64, values: [f64; 5]) -> Bar {
464        let [open, high, low, close, volume] = values;
465        Bar::new(time(minute), open, high, low, close, volume)
466    }
467
468    fn time(minute: i64) -> DateTime<Utc> {
469        DateTime::from_timestamp(1_704_067_200 + minute * 60, 0).unwrap()
470    }
471
472    /// Completion cutoffs, partial OHLCV, corrections, new/removed comparisons and group
473    /// isolation all use the same retained history. The final minute's spike must stay hidden.
474    #[test]
475    fn replay_windows_every_series_and_restores_retained_history() {
476        let minutes = vec![
477            bar(0, [10.0, 12.0, 9.0, 11.0, 2.0]),
478            bar(1, [11.0, 15.0, 10.0, 14.0, 3.0]),
479            bar(2, [14.0, 16.0, 8.0, 9.0, 4.0]),
480            bar(3, [9.0, 13.0, 7.0, 12.0, 5.0]),
481            bar(4, [12.0, 100.0, 1.0, 90.0, 6.0]),
482        ];
483        let coarse = vec![bar(0, [10.0, 100.0, 1.0, 90.0, 20.0])];
484        let mut a = Chart::new(BarData::from_bars(coarse.clone()));
485        a.set_timeframe(Timeframe::Min5);
486        let comparison = a.add_series(Default::default());
487        a.set_data(comparison, coarse.clone());
488        let mut b = Chart::new(BarData::from_bars(minutes.clone()));
489        b.set_timeframe(Timeframe::Min1);
490        let mut isolated = Chart::new(BarData::from_bars(minutes.clone()));
491        isolated.set_timeframe(Timeframe::Min1);
492        let mut layout = ChartLayout::default();
493        layout.set_sync_group(2, Some("other".into()));
494        layout.set_replay_time(&mut [&mut a, &mut b, &mut isolated], 0, Some(time(2)));
495        assert!(
496            a.bars(SeriesId::MAIN).is_empty(),
497            "unfinished coarse bar must be hidden"
498        );
499        assert!(a.bars(comparison).is_empty());
500        assert_eq!(b.bars(SeriesId::MAIN), &minutes[..2]);
501        assert_eq!(isolated.bars(SeriesId::MAIN), minutes);
502        assert!(a.set_replay_source(minutes.clone(), BarClock::new(Timeframe::Min1)));
503        // First open 10; max(12,15)=15; min(9,10)=9; last close 14; volume 2+3=5.
504        assert_eq!(
505            a.bars(SeriesId::MAIN),
506            [bar(0, [10.0, 15.0, 9.0, 14.0, 5.0])]
507        );
508        let prior = a.bars(SeriesId::MAIN).to_vec();
509        assert!(!a.set_replay_source(minutes.clone(), BarClock::new(Timeframe::Min15)));
510        assert_eq!(a.bars(SeriesId::MAIN), prior);
511        let mut invalid = minutes.clone();
512        invalid[0].volume = -1.0;
513        assert!(!a.set_replay_source(invalid, BarClock::new(Timeframe::Min1)));
514        for (cutoff, expected) in [
515            (4, bar(0, [10.0, 16.0, 7.0, 12.0, 14.0])),
516            (1, bar(0, [10.0, 12.0, 9.0, 11.0, 2.0])),
517            (5, coarse[0].clone()),
518        ] {
519            a.set_replay_time(Some(time(cutoff)));
520            assert_eq!(a.bars(SeriesId::MAIN), [expected]);
521        }
522        a.set_replay_time(Some(time(2)));
523        let added = a.add_series(Default::default());
524        a.set_data(added, coarse.clone());
525        assert!(a.bars(added).is_empty());
526        a.remove_series(comparison);
527        let correction = bar(5, [90.0, 95.0, 80.0, 85.0, 7.0]);
528        a.update(SeriesId::MAIN, correction.clone());
529        a.update(added, correction.clone());
530        assert_eq!(a.bars(SeriesId::MAIN), prior);
531        a.set_replay_time(Some(time(10)));
532        assert_eq!(
533            a.bars(SeriesId::MAIN),
534            [coarse[0].clone(), correction.clone()],
535            "completed chart history beyond the finer source must remain available"
536        );
537        a.set_replay_time(None);
538        assert_eq!(
539            a.bars(SeriesId::MAIN),
540            [coarse[0].clone(), correction.clone()]
541        );
542        assert_eq!(a.bars(added), [coarse[0].clone(), correction]);
543        assert!(
544            a.bars(comparison).is_empty(),
545            "exit cannot resurrect a removed series"
546        );
547        b.set_timeframe(Timeframe::Min5);
548        assert!(
549            b.bars(SeriesId::MAIN).is_empty(),
550            "a paused cutoff must follow interval changes"
551        );
552        b.set_timeframe(Timeframe::Min1);
553        assert_eq!(b.bars(SeriesId::MAIN), &minutes[..2]);
554        b.set_data(SeriesId::MAIN, minutes[..3].to_vec());
555        assert_eq!(b.bars(SeriesId::MAIN), &minutes[..2]);
556        b.set_replay_time(None);
557        assert_eq!(b.bars(SeriesId::MAIN), &minutes[..3]);
558
559        for limit in [false, true] {
560            let mut chart = Chart::new(BarData::from_bars(a.bars(SeriesId::MAIN).to_vec()));
561            chart.set_timeframe(Timeframe::Min5);
562            chart.set_replay_time(Some(time(2)));
563            assert!(chart.set_replay_source(minutes.clone(), BarClock::new(Timeframe::Min1)));
564            assert_eq!(chart.bars(SeriesId::MAIN).len(), 1);
565            if limit {
566                chart.set_bar_limit(SeriesId::MAIN, std::num::NonZeroUsize::new(1));
567            } else {
568                assert_eq!(chart.remove_oldest_bars(SeriesId::MAIN, 1), coarse);
569            }
570            assert!(
571                chart.bars(SeriesId::MAIN).is_empty(),
572                "finer replay source must not rebuild evicted bars; limit={limit}"
573            );
574            chart.set_replay_time(Some(time(10)));
575            assert_eq!(chart.bars(SeriesId::MAIN), &a.bars(SeriesId::MAIN)[1..]);
576        }
577    }
578
579    /// An added series brought back by undo shows what it would have shown had it never left:
580    /// the bars completed at the cutoff during replay, and all of them after the exit. A cutoff
581    /// at minute 2 completes the bars of minutes 0 and 1.
582    #[test]
583    fn undoing_a_series_removal_across_replay_keeps_its_history_and_the_cutoff() {
584        let minutes: Vec<_> = (0..5).map(|i| bar(i, [10.0; 5])).collect();
585        for removed_in_replay in [true, false] {
586            let mut chart = Chart::new(BarData::from_bars(minutes.clone()));
587            chart.set_timeframe(Timeframe::Min1);
588            let id = chart.add_series(Default::default());
589            chart.set_data(id, minutes.clone());
590            if removed_in_replay {
591                chart.set_replay_time(Some(time(2)));
592            }
593            assert!(chart.user_action(|chart| chart.remove_series(id)));
594            if removed_in_replay {
595                chart.set_replay_time(None);
596                assert!(chart.undo());
597            } else {
598                chart.set_replay_time(Some(time(2)));
599                assert!(chart.undo());
600                assert_eq!(chart.bars(id), &minutes[..2], "undone during replay");
601                chart.set_replay_time(None);
602            }
603            assert_eq!(
604                chart.bars(id),
605                minutes,
606                "removed_in_replay={removed_in_replay}"
607            );
608        }
609    }
610
611    #[cfg(feature = "studies")]
612    #[test]
613    fn rewind_clears_future_study_values_before_rendering() {
614        let mut chart = Chart::new(BarData::from_bars(
615            (0..8).map(|i| bar(i, [10.0 + i as f64; 5])).collect(),
616        ));
617        chart.set_timeframe(Timeframe::Min1);
618        chart.add_indicator(Box::new(crate::studies::SMA::new(2)));
619        chart.add_indicator(Box::new(crate::studies::EMA::new(2)));
620        chart.set_replay_time(Some(time(4)));
621        assert!(
622            chart
623                .indicators()
624                .all(|(_, study)| study.values().is_empty())
625        );
626        chart.refresh_studies(1.0);
627        chart.refresh_studies(2.0);
628        assert_eq!(
629            chart.indicators().next().unwrap().1.values().line(0)[3],
630            12.5
631        );
632        chart.set_replay_time(Some(time(2)));
633        assert!(
634            chart
635                .indicators()
636                .all(|(_, study)| study.values().is_empty())
637        );
638        chart.refresh_studies(3.0);
639        chart.refresh_studies(4.0);
640        assert_eq!(
641            chart.indicators().next().unwrap().1.values().line(0)[1],
642            10.5
643        );
644    }
645
646    /// Bar replay over recorded trades: one-trade bars build the unfinished chart bar from the
647    /// trades before each cursor position, two trades of one timestamp are two steps, a trade in
648    /// the next minute starts that bar, and seeking back shows the earlier bar again. The
649    /// chart's own minute 1, with its high of 14, never shows. Bars of two trades are refused: the
650    /// second opens at 00:01:00.5 and holds the trade of 00:02:00.25.
651    #[cfg(feature = "ui")]
652    #[test]
653    fn replay_advances_trade_by_trade() {
654        use crate::model::Tick;
655        use crate::ui::replay::{ReplayAction, ReplayController};
656        use chrono::TimeDelta;
657
658        let minute_0 = bar(0, [10.0, 12.0, 9.0, 11.0, 5.0]);
659        let mut chart = Chart::new(BarData::from_bars(vec![
660            minute_0.clone(),
661            bar(1, [11.0, 14.0, 10.0, 13.0, 6.0]),
662        ]));
663        chart.set_timeframe(Timeframe::Min1);
664        let at = |ms| time(1) + TimeDelta::milliseconds(ms);
665        let trades = [
666            Tick::new(at(0), 11.0, 1.0, None),
667            Tick::new(at(0), 13.0, 2.0, None),
668            Tick::new(at(500), 10.0, 1.0, None),
669            Tick::new(at(60_250), 12.0, 3.0, None),
670        ];
671        let one_trade = BarClock::new("1t".parse().unwrap());
672        let source = crate::data::aggregate_trades(&trades, one_trade.clone()).unwrap();
673        let mut cursor = ReplayController::new(source.clone(), Vec::new());
674        chart.set_replay_time(cursor.cutoff(&one_trade));
675        assert!(chart.set_replay_source(source, one_trade.clone()));
676        let steps = [
677            // Cutoff 00:01:00.000000001: the first trade.
678            (
679                ReplayAction::None,
680                vec![bar(1, [11.0, 11.0, 11.0, 11.0, 1.0])],
681            ),
682            // The second trade of 00:01:00, stamped one nanosecond later.
683            (
684                ReplayAction::StepForward,
685                vec![bar(1, [11.0, 13.0, 11.0, 13.0, 3.0])],
686            ),
687            (
688                ReplayAction::StepForward,
689                vec![bar(1, [11.0, 13.0, 10.0, 10.0, 4.0])],
690            ),
691            // Minute 1 is complete and built from its trades; minute 2 holds one.
692            (
693                ReplayAction::StepForward,
694                vec![
695                    bar(1, [11.0, 13.0, 10.0, 10.0, 4.0]),
696                    bar(2, [12.0, 12.0, 12.0, 12.0, 3.0]),
697                ],
698            ),
699            (
700                ReplayAction::JumpToBar(1),
701                vec![bar(1, [11.0, 13.0, 11.0, 13.0, 3.0])],
702            ),
703        ];
704        for (action, unfinished) in steps {
705            cursor.handle_action(action.clone());
706            chart.set_replay_time(cursor.cutoff(&one_trade));
707            let mut expected = vec![minute_0.clone()];
708            expected.extend(unfinished);
709            assert_eq!(chart.bars(SeriesId::MAIN), expected, "{action:?}");
710        }
711        let shown = chart.bars(SeriesId::MAIN).to_vec();
712        let two_trades = BarClock::new("2t".parse().unwrap());
713        let pairs = crate::data::aggregate_trades(&trades, two_trades.clone()).unwrap();
714        assert!(!chart.set_replay_source(pairs, two_trades));
715        assert_eq!(chart.bars(SeriesId::MAIN), shown);
716    }
717
718    #[test]
719    fn market_replay_rebuilds_chart_state_and_restores_the_live_feed() {
720        use crate::data::{MarketEvent, ReplayDriver, ReplayEvent};
721        use crate::model::{Instrument, Tick};
722        let mut events: Vec<_> = [(0, 10.0), (1, 12.0), (2, 99.0)]
723            .into_iter()
724            .enumerate()
725            .map(|(sequence, (minute, price))| {
726                ReplayEvent::new(
727                    "A",
728                    time(minute),
729                    sequence as u64,
730                    MarketEvent::Trade(Tick::new(time(minute), price, 1.0, None)),
731                )
732            })
733            .collect();
734        events.push(ReplayEvent::new(
735            "A",
736            time(0),
737            99,
738            MarketEvent::DepthSnapshot {
739                sequence: 0,
740                book: crate::model::OrderBook {
741                    ts: time(0),
742                    symbol: "A".into(),
743                    bids: vec![crate::model::OrderBookLevel {
744                        price: 9.0,
745                        size: 5.0,
746                        order_cnt: None,
747                    }],
748                    asks: Vec::new(),
749                },
750            },
751        ));
752        let load = || {
753            Arc::new(
754                ReplayDriver::new(
755                    events.clone(),
756                    vec![Instrument::new("A").with_tick_size(1.0)],
757                )
758                .unwrap(),
759            )
760        };
761        let live = load();
762        live.seek(time(2));
763        let mut chart = super::super::ChartBuilder::new()
764            .with_feed(live)
765            .with_symbol("A")
766            .with_timeframe(Timeframe::Min1)
767            .build();
768        let ctx = egui::Context::default();
769        for _ in 0..4 {
770            chart.pump(&ctx);
771        }
772        assert!(chart.order_book(SeriesId::MAIN).is_some());
773        chart.set_replay_time(Some(time(1)));
774        assert!(
775            chart.order_book(SeriesId::MAIN).is_none(),
776            "bar-only replay cannot expose a live book"
777        );
778        chart.set_replay_time(None);
779        assert!(chart.order_book(SeriesId::MAIN).is_some());
780        chart.set_volume_bubbles(Some(crate::chart::overlays::VolumeBubblesConfig {
781            min_size_filter: Some(2.0),
782            ..Default::default()
783        }));
784        let heatmap_config = crate::chart::overlays::LiquidityHeatmapConfig {
785            brightness: 0.5,
786            lower_cutoff: 2.0,
787            full_intensity_size: Some(10.0),
788        };
789        chart.set_liquidity_heatmap_config(heatmap_config);
790        chart.set_liquidity_heatmap(true);
791        chart.set_chart_type(crate::model::ChartType::VolumeFootprint);
792        for _ in 0..4 {
793            chart.pump(&ctx);
794        }
795        let original = chart.bars(SeriesId::MAIN).to_vec();
796        assert_eq!(original.len(), 3);
797        let replay = load();
798        replay.set_speed(0.0).unwrap();
799        replay.seek(time(0));
800        chart.set_market_replay(Some(replay.clone()));
801        for _ in 0..4 {
802            chart.pump(&ctx);
803        }
804        assert_eq!(chart.bars(SeriesId::MAIN), &original[..1]);
805        assert_eq!(chart.liquidity_heatmap_config(), heatmap_config);
806        assert_eq!(
807            chart
808                .feed_link
809                .as_ref()
810                .unwrap()
811                .bubbles
812                .as_ref()
813                .map(|bubbles| bubbles.config.min_size_filter),
814            Some(Some(2.0)),
815            "market replay keeps the configured trade bubbles"
816        );
817        assert_eq!(
818            chart
819                .feed_link
820                .as_ref()
821                .unwrap()
822                .footprint
823                .as_ref()
824                .unwrap()
825                .bars()
826                .len(),
827            1,
828            "a seek cannot retain future footprint rows"
829        );
830        replay.seek(time(1));
831        chart.pump(&ctx);
832        assert_eq!(chart.bars(SeriesId::MAIN), &original[..2]);
833        chart.set_timeframe(Timeframe::Min5);
834        for _ in 0..4 {
835            chart.pump(&ctx);
836        }
837        assert_eq!(
838            chart.bars(SeriesId::MAIN),
839            [bar(0, [10.0, 12.0, 10.0, 12.0, 2.0])]
840        );
841        chart.set_timeframe(Timeframe::Min1);
842        for _ in 0..4 {
843            chart.pump(&ctx);
844        }
845        chart.set_market_replay(None);
846        for _ in 0..4 {
847            chart.pump(&ctx);
848        }
849        assert_eq!(chart.bars(SeriesId::MAIN), original);
850        assert!(chart.replay_time().is_none());
851        assert!(chart.replay_driver().is_none());
852        assert_eq!(chart.liquidity_heatmap_config(), heatmap_config);
853
854        // A seek must also rebuild the coarser history that ordinary frames accumulated.
855        let books = (0..8)
856            .map(|minute| {
857                ReplayEvent::new(
858                    "A",
859                    time(minute),
860                    minute as u64,
861                    MarketEvent::DepthSnapshot {
862                        sequence: minute as u64,
863                        book: crate::model::OrderBook {
864                            ts: time(minute),
865                            symbol: "A".into(),
866                            bids: vec![crate::model::OrderBookLevel {
867                                price: 99.0,
868                                size: (minute + 1) as f64,
869                                order_cnt: None,
870                            }],
871                            asks: Vec::new(),
872                        },
873                    },
874                )
875            })
876            .collect();
877        let driver = Arc::new(
878            ReplayDriver::new(books, vec![Instrument::new("A").with_tick_size(1.0)]).unwrap(),
879        );
880        let mut chart = super::super::ChartBuilder::new()
881            .with_feed(driver.clone())
882            .with_symbol("A")
883            .with_timeframe(Timeframe::Min1)
884            .build();
885        chart.set_depth_history(Some(
886            crate::data::DepthHistory::new(crate::data::DepthHistoryConfig {
887                columns: 2,
888                cadence: chrono::TimeDelta::minutes(1),
889                ..Default::default()
890            })
891            .unwrap(),
892        ));
893        for _ in 0..4 {
894            chart.pump(&ctx);
895        }
896        driver.advance(std::time::Duration::ZERO).unwrap();
897        chart.pump(&ctx);
898        for _ in 1..8 {
899            driver.advance(std::time::Duration::from_secs(60)).unwrap();
900            chart.pump(&ctx);
901        }
902        let expected: Vec<_> = chart
903            .depth_history(SeriesId::MAIN)
904            .unwrap()
905            .columns()
906            .cloned()
907            .collect();
908        assert!(expected.len() > 2);
909        driver.seek(time(0));
910        chart.pump(&ctx);
911        driver.seek(time(7));
912        chart.pump(&ctx);
913        assert_eq!(
914            chart
915                .depth_history(SeriesId::MAIN)
916                .unwrap()
917                .columns()
918                .cloned()
919                .collect::<Vec<_>>(),
920            expected
921        );
922        let config = chart
923            .depth_history(SeriesId::MAIN)
924            .unwrap()
925            .config()
926            .clone();
927        chart.set_depth_history(Some(crate::data::DepthHistory::new(config).unwrap()));
928        for _ in 0..4 {
929            chart.pump(&ctx);
930        }
931        assert_eq!(
932            chart
933                .depth_history(SeriesId::MAIN)
934                .unwrap()
935                .columns()
936                .cloned()
937                .collect::<Vec<_>>(),
938            expected[expected.len() - 2..],
939            "enabling history at a paused cursor must load its latest recording page"
940        );
941    }
942}