Skip to main content

kestrel_chartkit/
parity.rs

1//! Reference-parity fixture harness: a standardized way to compare a Rust indicator's output
2//! series against confirmed external reference values, with timestamp alignment, automatic warmup
3//! handling,
4//! per-row or default tolerances, explicit missing-value rows, and an MTF-boundary-aware
5//! comparison mode — reusable across the whole porting scope instead of the hand-rolled
6//! per-indicator fixture parsing the existing golden tests use.
7
8use std::collections::HashMap;
9
10use crate::indicator::IndicatorOutput;
11use crate::runner::TimestampedOutput;
12use crate::timeframe::Timeframe;
13
14/// One expected reference row: timestamp, expected value, and an optional per-row tolerance
15/// overriding [`ParityFixture::default_tolerance`]. `expected.is_nan()` marks an explicitly
16/// missing/skip row (a bar with no confirmed reference value, e.g. inside its own warmup).
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct ParityFixtureRow {
19    pub timestamp: i64,
20    pub expected: f64,
21    pub tolerance: Option<f64>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct ParityFixture {
26    pub rows: Vec<ParityFixtureRow>,
27    pub default_tolerance: f64,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct ParityFixtureError {
32    pub line_number: usize,
33    pub line: String,
34    pub reason: String,
35}
36
37impl ParityFixture {
38    /// Parses `text`: one row per non-empty, non-`#`-comment line, `timestamp,expected` or
39    /// `timestamp,expected,tolerance` (CSV, whitespace-trimmed). `expected` may be `nan`/`NaN` to
40    /// mark an explicit missing-value row.
41    pub fn parse(text: &str, default_tolerance: f64) -> Result<Self, ParityFixtureError> {
42        let mut rows = Vec::new();
43        for (i, raw_line) in text.lines().enumerate() {
44            let line = raw_line.trim();
45            if line.is_empty() || line.starts_with('#') {
46                continue;
47            }
48            let parts: Vec<&str> = line.split(',').map(str::trim).collect();
49            if parts.len() < 2 || parts.len() > 3 {
50                return Err(ParityFixtureError {
51                    line_number: i + 1,
52                    line: raw_line.to_string(),
53                    reason: "expected 'timestamp,expected[,tolerance]'".to_string(),
54                });
55            }
56            let timestamp: i64 = parts[0].parse().map_err(|_| ParityFixtureError {
57                line_number: i + 1,
58                line: raw_line.to_string(),
59                reason: "invalid timestamp".to_string(),
60            })?;
61            let expected: f64 = parts[1].parse().map_err(|_| ParityFixtureError {
62                line_number: i + 1,
63                line: raw_line.to_string(),
64                reason: "invalid expected value".to_string(),
65            })?;
66            let tolerance = match parts.get(2) {
67                Some(s) => Some(s.parse().map_err(|_| ParityFixtureError {
68                    line_number: i + 1,
69                    line: raw_line.to_string(),
70                    reason: "invalid tolerance".to_string(),
71                })?),
72                None => None,
73            };
74            rows.push(ParityFixtureRow {
75                timestamp,
76                expected,
77                tolerance,
78            });
79        }
80        Ok(Self {
81            rows,
82            default_tolerance,
83        })
84    }
85}
86
87/// Outcome of comparing one fixture row against the actual output series.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum ParityOutcome {
90    Match {
91        timestamp: i64,
92        actual: f64,
93        expected: f64,
94    },
95    Mismatch {
96        timestamp: i64,
97        actual: f64,
98        expected: f64,
99        diff: f64,
100        tolerance: f64,
101    },
102    /// The fixture expects a confirmed value at this timestamp but the actual series has no bar
103    /// there, or the indicator was still in warmup (`None`) — a genuine parity gap.
104    MissingActual { timestamp: i64 },
105    /// The fixture explicitly marks this timestamp as having no reference value (`expected` was
106    /// `NaN`); not compared.
107    SkippedMissingExpected { timestamp: i64 },
108    /// The fixture expects a value at a timestamp that is not a confirmed higher-timeframe bucket
109    /// boundary under the configured MTF comparison mode; not compared.
110    SkippedNotConfirmedBoundary { timestamp: i64 },
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub struct ParityReport {
115    pub outcomes: Vec<ParityOutcome>,
116}
117
118impl ParityReport {
119    pub fn all_passed(&self) -> bool {
120        !self.outcomes.iter().any(|o| {
121            matches!(
122                o,
123                ParityOutcome::Mismatch { .. } | ParityOutcome::MissingActual { .. }
124            )
125        })
126    }
127
128    pub fn mismatches(&self) -> Vec<&ParityOutcome> {
129        self.outcomes
130            .iter()
131            .filter(|o| {
132                matches!(
133                    o,
134                    ParityOutcome::Mismatch { .. } | ParityOutcome::MissingActual { .. }
135                )
136            })
137            .collect()
138    }
139
140    pub fn matched_count(&self) -> usize {
141        self.outcomes
142            .iter()
143            .filter(|o| matches!(o, ParityOutcome::Match { .. }))
144            .count()
145    }
146}
147
148/// Compares `actual` (e.g. from [`crate::runner::run_batch`]) against `fixture`, aligning by
149/// timestamp. `value_selector` extracts the scalar to compare from each bar's
150/// [`IndicatorOutput`] (e.g. `|o| o.value`).
151pub fn compare_series(
152    actual: &[TimestampedOutput],
153    fixture: &ParityFixture,
154    value_selector: impl Fn(&IndicatorOutput) -> f64,
155) -> ParityReport {
156    compare_series_filtered(actual, fixture, value_selector, |_| true)
157}
158
159/// Like [`compare_series`], but only compares fixture rows whose timestamp is itself a `target_tf`
160/// bucket boundary (via `Timeframe::bucket_start`) — the MTF-boundary-aware mode, for
161/// validating a port's higher-timeframe output only at points where the confirmed reference HTF value is
162/// actually available, not mid-bucket.
163pub fn compare_series_at_timeframe_boundaries(
164    actual: &[TimestampedOutput],
165    fixture: &ParityFixture,
166    value_selector: impl Fn(&IndicatorOutput) -> f64,
167    target_tf: Timeframe,
168    utc_offset_seconds: i32,
169) -> ParityReport {
170    compare_series_filtered(actual, fixture, value_selector, |timestamp| {
171        target_tf.bucket_start(timestamp, utc_offset_seconds) == timestamp
172    })
173}
174
175fn compare_series_filtered(
176    actual: &[TimestampedOutput],
177    fixture: &ParityFixture,
178    value_selector: impl Fn(&IndicatorOutput) -> f64,
179    boundary_filter: impl Fn(i64) -> bool,
180) -> ParityReport {
181    let actual_map: HashMap<i64, Option<f64>> = actual
182        .iter()
183        .map(|entry| (entry.timestamp, entry.output.as_ref().map(&value_selector)))
184        .collect();
185
186    let outcomes = fixture
187        .rows
188        .iter()
189        .map(|row| {
190            if row.expected.is_nan() {
191                return ParityOutcome::SkippedMissingExpected {
192                    timestamp: row.timestamp,
193                };
194            }
195            if !boundary_filter(row.timestamp) {
196                return ParityOutcome::SkippedNotConfirmedBoundary {
197                    timestamp: row.timestamp,
198                };
199            }
200            match actual_map.get(&row.timestamp) {
201                Some(Some(actual)) => {
202                    let tolerance = row.tolerance.unwrap_or(fixture.default_tolerance);
203                    let diff = (actual - row.expected).abs();
204                    if diff <= tolerance {
205                        ParityOutcome::Match {
206                            timestamp: row.timestamp,
207                            actual: *actual,
208                            expected: row.expected,
209                        }
210                    } else {
211                        ParityOutcome::Mismatch {
212                            timestamp: row.timestamp,
213                            actual: *actual,
214                            expected: row.expected,
215                            diff,
216                            tolerance,
217                        }
218                    }
219                }
220                _ => ParityOutcome::MissingActual {
221                    timestamp: row.timestamp,
222                },
223            }
224        })
225        .collect();
226
227    ParityReport { outcomes }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn output_series(pairs: &[(i64, Option<f64>)]) -> Vec<TimestampedOutput> {
235        pairs
236            .iter()
237            .map(|&(ts, v)| TimestampedOutput {
238                timestamp: ts,
239                output: v.map(IndicatorOutput::new),
240            })
241            .collect()
242    }
243
244    #[test]
245    fn test_parses_fixture_with_optional_tolerance_and_comments() {
246        let text = "# comment\n0,1.5\n60,2.0,0.01\n\n120,nan\n";
247        let fixture = ParityFixture::parse(text, 0.001).unwrap();
248        assert_eq!(fixture.rows.len(), 3);
249        assert_eq!(fixture.rows[0].tolerance, None);
250        assert_eq!(fixture.rows[1].tolerance, Some(0.01));
251        assert!(fixture.rows[2].expected.is_nan());
252    }
253
254    #[test]
255    fn test_parse_rejects_malformed_row() {
256        let err = ParityFixture::parse("not,a,valid,row,here", 0.001).unwrap_err();
257        assert_eq!(err.line_number, 1);
258    }
259
260    #[test]
261    fn test_match_within_tolerance() {
262        let actual = output_series(&[(0, Some(1.4995))]);
263        let fixture = ParityFixture::parse("0,1.5", 0.001).unwrap();
264        let report = compare_series(&actual, &fixture, |o| o.value);
265        assert!(report.all_passed());
266        assert_eq!(report.matched_count(), 1);
267    }
268
269    #[test]
270    fn test_mismatch_beyond_tolerance() {
271        let actual = output_series(&[(0, Some(2.0))]);
272        let fixture = ParityFixture::parse("0,1.5,0.01", 0.001).unwrap();
273        let report = compare_series(&actual, &fixture, |o| o.value);
274        assert!(!report.all_passed());
275        assert_eq!(report.mismatches().len(), 1);
276    }
277
278    #[test]
279    fn test_missing_actual_during_warmup_is_a_gap_not_silently_skipped() {
280        let actual = output_series(&[(0, None)]);
281        let fixture = ParityFixture::parse("0,1.5", 0.001).unwrap();
282        let report = compare_series(&actual, &fixture, |o| o.value);
283        assert!(!report.all_passed());
284        assert!(matches!(
285            report.outcomes[0],
286            ParityOutcome::MissingActual { .. }
287        ));
288    }
289
290    #[test]
291    fn test_explicit_missing_expected_is_skipped_not_a_failure() {
292        let actual = output_series(&[(0, None)]);
293        let fixture = ParityFixture::parse("0,nan", 0.001).unwrap();
294        let report = compare_series(&actual, &fixture, |o| o.value);
295        assert!(report.all_passed());
296        assert!(matches!(
297            report.outcomes[0],
298            ParityOutcome::SkippedMissingExpected { .. }
299        ));
300    }
301
302    #[test]
303    fn test_mtf_boundary_mode_skips_non_boundary_rows() {
304        let actual = output_series(&[(30, Some(1.0)), (300, Some(2.0))]);
305        // 30 is not a 5-minute (300s) bucket boundary; 300 is.
306        let fixture = ParityFixture::parse("30,1.0\n300,2.0", 0.001).unwrap();
307        let report = compare_series_at_timeframe_boundaries(
308            &actual,
309            &fixture,
310            |o| o.value,
311            Timeframe::Minute(5),
312            0,
313        );
314        assert!(matches!(
315            report.outcomes[0],
316            ParityOutcome::SkippedNotConfirmedBoundary { .. }
317        ));
318        assert!(matches!(report.outcomes[1], ParityOutcome::Match { .. }));
319    }
320}