Skip to main content

navi_vm/
compactor.rs

1//! Incremental event compactor for tracking realtime bar lifecycle.
2//!
3//! [`EventCompactor`] sits between an [`Event`] stream and a downstream sink,
4//! forwarding events according to [`EventCompactionRules`] and notifying the
5//! sink whenever a realtime bar becomes *confirmed* (closed).
6
7use navi_types::BarStartEvent;
8
9use crate::{BarState, Event};
10
11/// Configuration rules for [`EventCompactor`].
12#[derive(Debug, Clone, Default)]
13pub struct EventCompactionRules {
14    /// When `true`, [`Event::Log`] events are dropped instead of forwarded.
15    pub filter_log: bool,
16    /// When `true`, [`Event::Alert`] events are dropped instead of forwarded.
17    pub filter_alert: bool,
18}
19
20/// Reason why a realtime bar was confirmed.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum RealtimeConfirmReason {
23    /// A new realtime bar with a different timestamp arrived, closing the
24    /// previous one.
25    Rollover,
26    /// The stream ended while a realtime bar was still open; confirmed at
27    /// [`EventCompactor::finish`].
28    StreamEnd,
29}
30
31/// Information about a confirmed realtime bar.
32#[derive(Debug, Clone)]
33pub struct RealtimeConfirmedInfo {
34    /// Zero-based bar index of the confirmed bar.
35    pub bar_index: usize,
36    /// Unix-millisecond timestamp of the confirmed bar.
37    pub bar_time: i64,
38    /// Reason the bar was confirmed.
39    pub reason: RealtimeConfirmReason,
40    /// Number of events forwarded to the sink for this bar since its
41    /// [`BarState::RealtimeNew`] phase began.
42    ///
43    /// Does **not** include stream-level events (`ScriptInfo`, `Warning`,
44    /// `HistoryEnd`) that were emitted outside of any bar.
45    ///
46    /// ## Use cases
47    ///
48    /// - **Trimming intermediate state**: when the sink appends events to a
49    ///   buffer, `event_count` tells how many trailing entries belong to this
50    ///   bar.  The caller can archive or delete them after confirmation.
51    /// - **Batch commit**: after a `RealtimeConfirmed` notification, the sink
52    ///   can atomically commit or discard the last `event_count` writes.
53    /// - **Audit/replay**: `event_count` gives the total per-bar event count
54    ///   for indexing or storage partitioning.
55    pub event_count: usize,
56}
57
58/// Callback interface for [`EventCompactor`].
59pub trait EventCompactionSink {
60    /// Called for every event that passes the compaction rules.
61    fn on_event(&mut self, event: Event);
62    /// Called when a realtime bar transitions to confirmed.
63    fn on_realtime_confirmed(&mut self, info: RealtimeConfirmedInfo);
64}
65
66/// Per-realtime-bar tracking state.
67struct RealtimeBarTracking {
68    bar_index: usize,
69    bar_time: i64,
70    event_count: usize,
71}
72
73/// Incremental compactor that tracks realtime bar lifecycle.
74///
75/// Feed events via [`push`](Self::push) and call [`finish`](Self::finish) when
76/// the stream ends.  The compactor:
77///
78/// - Forwards history events unchanged.
79/// - Counts events per realtime bar and notifies
80///   [`EventCompactionSink::on_realtime_confirmed`] on rollover or stream end.
81/// - Optionally drops [`Event::Log`] and/or [`Event::Alert`] events.
82pub struct EventCompactor {
83    rules: EventCompactionRules,
84    current_realtime: Option<RealtimeBarTracking>,
85}
86
87impl EventCompactor {
88    /// Create a new compactor with the given rules.
89    pub fn new(rules: EventCompactionRules) -> Self {
90        Self {
91            rules,
92            current_realtime: None,
93        }
94    }
95
96    /// Push one event through the compactor.
97    ///
98    /// The event is forwarded to `sink.on_event` (unless filtered) and the
99    /// realtime bar state is updated accordingly.  When a new realtime bar
100    /// rolls over the previous one, `sink.on_realtime_confirmed` is called
101    /// before forwarding the new `BarStart`.
102    pub fn push<S>(&mut self, event: Event, sink: &mut S)
103    where
104        S: EventCompactionSink,
105    {
106        match &event {
107            Event::BarStart(bs) => self.handle_bar_start(bs.clone(), event, sink),
108
109            Event::BarEnd => {
110                if let Some(rt) = &mut self.current_realtime {
111                    rt.event_count += 1;
112                }
113                sink.on_event(event);
114            }
115
116            // Stream-level events — forward, do not count.
117            Event::SessionInfo { .. } | Event::HistoryEnd | Event::Warning(_) => {
118                sink.on_event(event);
119            }
120
121            Event::Log(_) => {
122                if !self.rules.filter_log {
123                    if let Some(rt) = &mut self.current_realtime {
124                        rt.event_count += 1;
125                    }
126                    sink.on_event(event);
127                }
128            }
129
130            Event::Alert(_) => {
131                if !self.rules.filter_alert {
132                    if let Some(rt) = &mut self.current_realtime {
133                        rt.event_count += 1;
134                    }
135                    sink.on_event(event);
136                }
137            }
138
139            Event::Draw(_) | Event::Strategy(_) => {
140                if let Some(rt) = &mut self.current_realtime {
141                    rt.event_count += 1;
142                }
143                sink.on_event(event);
144            }
145        }
146    }
147
148    /// Signal stream completion.
149    ///
150    /// If a realtime bar is still open, it is confirmed with
151    /// [`RealtimeConfirmReason::StreamEnd`].
152    pub fn finish<S>(&mut self, sink: &mut S)
153    where
154        S: EventCompactionSink,
155    {
156        self.emit_confirmed(RealtimeConfirmReason::StreamEnd, sink);
157    }
158
159    fn handle_bar_start<S>(&mut self, bs: BarStartEvent, event: Event, sink: &mut S)
160    where
161        S: EventCompactionSink,
162    {
163        match bs.bar_state {
164            BarState::History => {
165                // History bars are not tracked; just forward.
166                sink.on_event(event);
167            }
168
169            BarState::RealtimeNew => {
170                // Check for rollover: a new realtime bar with a different
171                // timestamp means the previous one is confirmed.
172                let rollover = self
173                    .current_realtime
174                    .as_ref()
175                    .is_some_and(|rt| rt.bar_time != bs.candlestick.time);
176                if rollover {
177                    self.emit_confirmed(RealtimeConfirmReason::Rollover, sink);
178                }
179                // Start tracking the new bar.
180                self.current_realtime = Some(RealtimeBarTracking {
181                    bar_index: bs.bar_index,
182                    bar_time: bs.candlestick.time,
183                    event_count: 1, // count this BarStart
184                });
185                sink.on_event(event);
186            }
187
188            BarState::RealtimeUpdate | BarState::RealtimeConfirmed => {
189                if let Some(rt) = &mut self.current_realtime {
190                    rt.event_count += 1;
191                }
192                sink.on_event(event);
193            }
194        }
195    }
196
197    fn emit_confirmed<S>(&mut self, reason: RealtimeConfirmReason, sink: &mut S)
198    where
199        S: EventCompactionSink,
200    {
201        if let Some(rt) = self.current_realtime.take() {
202            sink.on_realtime_confirmed(RealtimeConfirmedInfo {
203                bar_index: rt.bar_index,
204                bar_time: rt.bar_time,
205                reason,
206                event_count: rt.event_count,
207            });
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use navi_types::{BarStartEvent, BarState, Candlestick};
215    use navi_visuals::DrawEvent;
216
217    use super::*;
218    use crate::events::{AlertEvent, LogEvent, LogLevel};
219
220    // ── Helpers ─────────────────────────────────────────────────────────────
221
222    fn make_candlestick(time: i64) -> Candlestick {
223        use crate::TradeSession;
224        Candlestick::new(time, 1.0, 2.0, 0.5, 1.5, 1000.0, 0.0, TradeSession::Regular)
225    }
226
227    fn bar_start(bar_index: usize, time: i64, state: BarState) -> Event {
228        Event::BarStart(BarStartEvent {
229            bar_index,
230            candlestick: make_candlestick(time),
231            bar_state: state,
232        })
233    }
234
235    /// A simple collecting sink used in tests.
236    #[derive(Default)]
237    struct CollectSink {
238        events: Vec<Event>,
239        confirmations: Vec<RealtimeConfirmedInfo>,
240    }
241
242    impl EventCompactionSink for CollectSink {
243        fn on_event(&mut self, event: Event) {
244            self.events.push(event);
245        }
246        fn on_realtime_confirmed(&mut self, info: RealtimeConfirmedInfo) {
247            self.confirmations.push(info);
248        }
249    }
250
251    const T0: i64 = 1_700_000_000_000;
252    const DAY: i64 = 86_400_000;
253
254    // ── Test: history bars pass through without triggering confirmations ────
255
256    #[test]
257    fn history_bars_pass_through() {
258        let mut compactor = EventCompactor::new(EventCompactionRules::default());
259        let mut sink = CollectSink::default();
260
261        compactor.push(bar_start(0, T0, BarState::History), &mut sink);
262        compactor.push(Event::BarEnd, &mut sink);
263        compactor.push(bar_start(1, T0 + DAY, BarState::History), &mut sink);
264        compactor.push(Event::BarEnd, &mut sink);
265        compactor.finish(&mut sink);
266
267        assert_eq!(sink.events.len(), 4);
268        assert!(
269            sink.confirmations.is_empty(),
270            "no realtime bars → no confirmations"
271        );
272    }
273
274    // ── Test: realtime rollover triggers Rollover confirmation ────────────
275
276    #[test]
277    fn realtime_rollover() {
278        let mut compactor = EventCompactor::new(EventCompactionRules::default());
279        let mut sink = CollectSink::default();
280
281        // Bar 1: RealtimeNew then RealtimeUpdate then RealtimeConfirmed
282        compactor.push(bar_start(1, T0 + DAY, BarState::RealtimeNew), &mut sink);
283        compactor.push(Event::BarEnd, &mut sink);
284        compactor.push(bar_start(1, T0 + DAY, BarState::RealtimeUpdate), &mut sink);
285        compactor.push(Event::BarEnd, &mut sink);
286        compactor.push(
287            bar_start(1, T0 + DAY, BarState::RealtimeConfirmed),
288            &mut sink,
289        );
290        compactor.push(Event::BarEnd, &mut sink);
291
292        // No confirmation yet (still on same bar).
293        assert!(sink.confirmations.is_empty());
294
295        // Bar 2: new timestamp → rollover
296        compactor.push(bar_start(2, T0 + DAY * 2, BarState::RealtimeNew), &mut sink);
297        compactor.push(Event::BarEnd, &mut sink);
298
299        // Exactly 1 Rollover confirmation for bar 1.
300        assert_eq!(sink.confirmations.len(), 1);
301        let c = &sink.confirmations[0];
302        assert_eq!(c.bar_index, 1);
303        assert_eq!(c.bar_time, T0 + DAY);
304        assert_eq!(c.reason, RealtimeConfirmReason::Rollover);
305        // 3 BarStarts + 3 BarEnds = 6 events
306        assert_eq!(c.event_count, 6);
307
308        // Finish closes bar 2.
309        compactor.finish(&mut sink);
310        assert_eq!(sink.confirmations.len(), 2);
311        assert_eq!(
312            sink.confirmations[1].reason,
313            RealtimeConfirmReason::StreamEnd
314        );
315    }
316
317    // ── Test: stream end triggers StreamEnd confirmation ──────────────────
318
319    #[test]
320    fn realtime_stream_end() {
321        let mut compactor = EventCompactor::new(EventCompactionRules::default());
322        let mut sink = CollectSink::default();
323
324        compactor.push(bar_start(0, T0, BarState::RealtimeNew), &mut sink);
325        compactor.push(Event::BarEnd, &mut sink);
326        compactor.finish(&mut sink);
327
328        assert_eq!(sink.confirmations.len(), 1);
329        assert_eq!(
330            sink.confirmations[0].reason,
331            RealtimeConfirmReason::StreamEnd
332        );
333        assert_eq!(sink.confirmations[0].event_count, 2); // BarStart + BarEnd
334    }
335
336    // ── Test: filter_log=true drops Log events ────────────────────────────
337
338    #[test]
339    fn filter_log_drops_event() {
340        let rules = EventCompactionRules {
341            filter_log: true,
342            ..Default::default()
343        };
344        let mut compactor = EventCompactor::new(rules);
345        let mut sink = CollectSink::default();
346
347        compactor.push(bar_start(0, T0, BarState::RealtimeNew), &mut sink);
348        compactor.push(
349            Event::Log(LogEvent {
350                level: LogLevel::Info,
351                message: "hello".into(),
352            }),
353            &mut sink,
354        );
355        compactor.push(Event::BarEnd, &mut sink);
356        compactor.finish(&mut sink);
357
358        // Log event should be dropped.
359        let log_count = sink
360            .events
361            .iter()
362            .filter(|e| matches!(e, Event::Log(_)))
363            .count();
364        assert_eq!(log_count, 0);
365
366        // event_count should not include the dropped log.
367        assert_eq!(sink.confirmations[0].event_count, 2); // BarStart + BarEnd
368    }
369
370    // ── Test: bar-outside events are not counted ──────────────────────────
371
372    #[test]
373    fn bar_outside_events_not_counted() {
374        let mut compactor = EventCompactor::new(EventCompactionRules::default());
375        let mut sink = CollectSink::default();
376
377        // HistoryEnd is a stream-level event emitted outside bars.
378        compactor.push(Event::HistoryEnd, &mut sink);
379
380        // Now start a realtime bar.
381        compactor.push(bar_start(0, T0, BarState::RealtimeNew), &mut sink);
382        compactor.push(Event::BarEnd, &mut sink);
383        compactor.finish(&mut sink);
384
385        // HistoryEnd is forwarded but not counted.
386        let forwarded_outside: Vec<_> = sink
387            .events
388            .iter()
389            .filter(|e| matches!(e, Event::HistoryEnd))
390            .collect();
391        assert_eq!(forwarded_outside.len(), 1);
392
393        // Only BarStart + BarEnd counted.
394        assert_eq!(sink.confirmations[0].event_count, 2);
395    }
396
397    // ── Test: filter_alert=true drops Alert events ────────────────────────
398
399    #[test]
400    fn filter_alert_drops_event() {
401        let rules = EventCompactionRules {
402            filter_alert: true,
403            ..Default::default()
404        };
405        let mut compactor = EventCompactor::new(rules);
406        let mut sink = CollectSink::default();
407
408        compactor.push(bar_start(0, T0, BarState::RealtimeNew), &mut sink);
409        compactor.push(
410            Event::Alert(AlertEvent {
411                id: None,
412                message: "alert!".into(),
413            }),
414            &mut sink,
415        );
416        compactor.push(Event::BarEnd, &mut sink);
417        compactor.finish(&mut sink);
418
419        let alert_count = sink
420            .events
421            .iter()
422            .filter(|e| matches!(e, Event::Alert(_)))
423            .count();
424        assert_eq!(alert_count, 0);
425        assert_eq!(sink.confirmations[0].event_count, 2);
426    }
427
428    // ── Test: Draw events are counted ─────────────────────────────────────
429
430    #[test]
431    fn draw_events_counted_in_realtime() {
432        let mut compactor = EventCompactor::new(EventCompactionRules::default());
433        let mut sink = CollectSink::default();
434
435        compactor.push(bar_start(0, T0, BarState::RealtimeNew), &mut sink);
436        compactor.push(Event::Draw(DrawEvent::NewBar { bar_index: 0 }), &mut sink);
437        compactor.push(Event::BarEnd, &mut sink);
438        compactor.finish(&mut sink);
439
440        // BarStart + Draw + BarEnd = 3
441        assert_eq!(sink.confirmations[0].event_count, 3);
442    }
443
444    // ── Test: history events do not start realtime tracking ───────────────
445
446    #[test]
447    fn history_does_not_start_realtime_tracking() {
448        let mut compactor = EventCompactor::new(EventCompactionRules::default());
449        let mut sink = CollectSink::default();
450
451        compactor.push(bar_start(0, T0, BarState::History), &mut sink);
452        compactor.push(Event::BarEnd, &mut sink);
453        compactor.finish(&mut sink);
454
455        assert!(sink.confirmations.is_empty());
456        assert!(compactor.current_realtime.is_none());
457    }
458
459    // ── Test: filter_log=true does not affect ChartAccumulator output ─────
460
461    #[test]
462    fn equivalence_chart_accumulator() {
463        use navi_visuals::{ChartAccumulator, ChartEvent};
464
465        let events_with_log = vec![
466            Event::HistoryEnd,
467            bar_start(0, T0, BarState::RealtimeNew),
468            Event::Draw(DrawEvent::NewBar { bar_index: 0 }),
469            Event::Log(LogEvent {
470                level: LogLevel::Info,
471                message: "noise".into(),
472            }),
473            Event::BarEnd,
474        ];
475        let events_without_log: Vec<Event> = events_with_log
476            .iter()
477            .filter(|e| !matches!(e, Event::Log(_)))
478            .cloned()
479            .collect();
480
481        // Reference: apply events (minus Log) directly to ChartAccumulator.
482        let mut ref_acc = ChartAccumulator::new();
483        for event in events_without_log {
484            if let Ok(ce) = ChartEvent::try_from(event) {
485                ref_acc.apply(ce);
486            }
487        }
488
489        // Compactor path: feed all events with filter_log=true.
490        struct ChartSink {
491            acc: ChartAccumulator,
492        }
493        impl EventCompactionSink for ChartSink {
494            fn on_event(&mut self, event: Event) {
495                if let Ok(ce) = ChartEvent::try_from(event) {
496                    self.acc.apply(ce);
497                }
498            }
499            fn on_realtime_confirmed(&mut self, _: RealtimeConfirmedInfo) {}
500        }
501        let mut sink = ChartSink {
502            acc: ChartAccumulator::new(),
503        };
504        let mut compactor = EventCompactor::new(EventCompactionRules {
505            filter_log: true,
506            ..Default::default()
507        });
508        for event in events_with_log {
509            compactor.push(event, &mut sink);
510        }
511        compactor.finish(&mut sink);
512
513        assert_eq!(
514            format!("{:?}", ref_acc.chart()),
515            format!("{:?}", sink.acc.chart()),
516            "chart state must be identical whether Log events are present or filtered"
517        );
518    }
519
520    // ── Test: filter_log=true does not affect StrategyAccumulator output ──
521
522    #[test]
523    fn equivalence_strategy_accumulator() {
524        use navi_strategy::{StrategyAccumulator, StrategyEvent};
525
526        let events_with_log = vec![
527            Event::HistoryEnd,
528            bar_start(0, T0, BarState::RealtimeNew),
529            Event::Strategy(StrategyEvent::EquitySnapshot {
530                equity: 1000.0,
531                buy_hold_equity: 950.0,
532            }),
533            Event::Log(LogEvent {
534                level: LogLevel::Info,
535                message: "noise".into(),
536            }),
537            Event::BarEnd,
538        ];
539        let events_without_log: Vec<Event> = events_with_log
540            .iter()
541            .filter(|e| !matches!(e, Event::Log(_)))
542            .cloned()
543            .collect();
544
545        // Reference: apply events (minus Log) directly to StrategyAccumulator.
546        let mut ref_acc = StrategyAccumulator::new();
547        for event in events_without_log {
548            if let Event::Strategy(e) = event {
549                ref_acc.apply(e);
550            }
551        }
552
553        // Compactor path: feed all events with filter_log=true.
554        struct StrategySink {
555            acc: StrategyAccumulator,
556        }
557        impl EventCompactionSink for StrategySink {
558            fn on_event(&mut self, event: Event) {
559                if let Event::Strategy(e) = event {
560                    self.acc.apply(e);
561                }
562            }
563            fn on_realtime_confirmed(&mut self, _: RealtimeConfirmedInfo) {}
564        }
565        let mut sink = StrategySink {
566            acc: StrategyAccumulator::new(),
567        };
568        let mut compactor = EventCompactor::new(EventCompactionRules {
569            filter_log: true,
570            ..Default::default()
571        });
572        for event in events_with_log {
573            compactor.push(event, &mut sink);
574        }
575        compactor.finish(&mut sink);
576
577        assert_eq!(
578            format!("{:?}", ref_acc.report()),
579            format!("{:?}", sink.acc.report()),
580            "strategy report must be identical whether Log events are present or filtered"
581        );
582    }
583}