Skip to main content

kestrel_chartkit/
parity.rs

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