Skip to main content

kestrel_chartkit/
runner.rs

1//! Batch and replay execution over a full bar history.
2//!
3//! [`Indicator::on_bar`](crate::indicator::Indicator::on_bar) processes one bar and returns the
4//! latest value; consumers wanting a full, timestamp-aligned output series over a backfill
5//! (charting, backtesting, golden-fixture generation) previously had to hand-roll the loop. These
6//! helpers standardize it: deterministic (always start from [`Indicator::reset`]), timestamp-
7//! aligned (one entry per input bar, in order, `None` during warmup), and reproducible (pure
8//! function of the indicator + bar slice, safe to call repeatedly for replay).
9
10use crate::indicator::{Indicator, IndicatorOutput};
11use crate::model::{Bar, BarValidationError};
12
13/// One entry of a batch/replay output series: the source bar's timestamp paired with the
14/// indicator's output for that bar (`None` while still inside the warmup period).
15#[derive(Debug, Clone, PartialEq)]
16pub struct TimestampedOutput {
17    pub timestamp: i64,
18    pub output: Option<IndicatorOutput>,
19}
20
21/// Resets `indicator`, then feeds `bars` through it in order, collecting one [`TimestampedOutput`]
22/// per bar. Two calls with the same indicator type and `bars` slice always produce identical
23/// results (deterministic backfill / reproducible replay).
24pub fn run_batch<I: Indicator + ?Sized>(indicator: &mut I, bars: &[Bar]) -> Vec<TimestampedOutput> {
25    indicator.reset();
26    bars.iter()
27        .map(|bar| TimestampedOutput {
28            timestamp: bar.timestamp,
29            output: indicator.on_bar(bar),
30        })
31        .collect()
32}
33
34/// Like [`run_batch`], but validates each bar via
35/// [`Indicator::on_checked_bar`] and stops at the
36/// first invalid bar, returning the entries collected so far plus the validation error.
37pub fn run_batch_checked<I: Indicator + ?Sized>(
38    indicator: &mut I,
39    bars: &[Bar],
40) -> Result<Vec<TimestampedOutput>, (Vec<TimestampedOutput>, BarValidationError)> {
41    indicator.reset();
42    let mut series = Vec::with_capacity(bars.len());
43    for bar in bars {
44        match indicator.on_checked_bar(bar) {
45            Ok(output) => series.push(TimestampedOutput {
46                timestamp: bar.timestamp,
47                output,
48            }),
49            Err(err) => return Err((series, err)),
50        }
51    }
52    Ok(series)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::indicator::moving_averages::SmaEngine;
59
60    /// Builds valid bars around each close (offset so `low = close + 100.0 - 1.0` stays positive).
61    fn sample_bars(closes: &[f64]) -> Vec<Bar> {
62        closes
63            .iter()
64            .enumerate()
65            .map(|(i, &c)| {
66                let c = c + 100.0;
67                Bar::new((i as i64) * 60, c, c + 1.0, c - 1.0, c, 100.0)
68            })
69            .collect()
70    }
71
72    #[test]
73    fn test_run_batch_is_timestamp_aligned_and_warmup_aware() {
74        let bars = sample_bars(&[1.0, 2.0, 3.0, 4.0, 5.0]);
75        let mut sma = SmaEngine::new(3);
76        let series = run_batch(&mut sma, &bars);
77
78        assert_eq!(series.len(), bars.len());
79        for (entry, bar) in series.iter().zip(&bars) {
80            assert_eq!(entry.timestamp, bar.timestamp);
81        }
82        // Warmup: SmaEngine needs 3 bars before it emits a value.
83        assert!(series[0].output.is_none());
84        assert!(series[1].output.is_none());
85        assert!(series[2].output.is_some());
86        assert_eq!(series[2].output.as_ref().unwrap().value, 102.0);
87        assert_eq!(series[4].output.as_ref().unwrap().value, 104.0);
88    }
89
90    #[test]
91    fn test_run_batch_is_deterministic_and_resets_prior_state() {
92        let bars = sample_bars(&[1.0, 2.0, 3.0, 4.0, 5.0]);
93        let mut sma = SmaEngine::new(3);
94
95        let first = run_batch(&mut sma, &bars);
96        // Re-running over the same indicator instance must reset first, so replay is idempotent.
97        let second = run_batch(&mut sma, &bars);
98        assert_eq!(first, second);
99    }
100
101    #[test]
102    fn test_run_batch_checked_stops_at_invalid_bar() {
103        let mut bars = sample_bars(&[10.0, 20.0]);
104        bars.push(Bar::new(120, f64::NAN, 1.0, -1.0, 1.0, 100.0));
105        bars.push(Bar::new(180, 3.0, 4.0, 2.0, 3.0, 100.0));
106
107        let mut sma = SmaEngine::new(2);
108        let result = run_batch_checked(&mut sma, &bars);
109        let (partial, err) = result.unwrap_err();
110        assert_eq!(partial.len(), 2);
111        assert_eq!(err, BarValidationError::NonFiniteValue);
112    }
113}