1use std::collections::HashMap;
9
10use crate::indicator::IndicatorOutput;
11use crate::runner::TimestampedOutput;
12use crate::timeframe::Timeframe;
13
14#[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 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#[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 MissingActual { timestamp: i64 },
105 SkippedMissingExpected { timestamp: i64 },
108 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
148pub 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
159pub 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 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}