1use std::collections::HashMap;
8
9use crate::indicator::IndicatorOutput;
10use crate::runner::TimestampedOutput;
11use crate::timeframe::Timeframe;
12
13#[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 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#[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 MissingActual { timestamp: i64 },
104 SkippedMissingExpected { timestamp: i64 },
107 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
147pub 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
158pub 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 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}