Skip to main content

ta_benchmarks/
catalogue_matrix.rs

1//! Deterministic fixtures and semantic gates for Catalogue measurements.
2
3pub const ABS_TOLERANCE: f64 = 1.0e-9;
4pub const REL_TOLERANCE: f64 = 1.0e-12;
5
6#[derive(Clone, Debug, PartialEq)]
7pub struct Fixture {
8    pub open: Vec<f64>,
9    pub high: Vec<f64>,
10    pub low: Vec<f64>,
11    pub close: Vec<f64>,
12    pub volume: Vec<f64>,
13    pub auxiliary: Vec<f64>,
14}
15
16impl Fixture {
17    pub fn len(&self) -> usize {
18        self.close.len()
19    }
20
21    pub fn is_empty(&self) -> bool {
22        self.close.is_empty()
23    }
24
25    pub fn validate(&self) -> Result<(), String> {
26        let length = self.close.len();
27        for (name, values) in [
28            ("open", &self.open),
29            ("high", &self.high),
30            ("low", &self.low),
31            ("volume", &self.volume),
32            ("auxiliary", &self.auxiliary),
33        ] {
34            if values.len() != length {
35                return Err(format!(
36                    "fixture {name} length {} differs from close length {length}",
37                    values.len()
38                ));
39            }
40        }
41        for index in 0..length {
42            let values = [
43                self.open[index],
44                self.high[index],
45                self.low[index],
46                self.close[index],
47                self.volume[index],
48                self.auxiliary[index],
49            ];
50            if values.iter().any(|value| !value.is_finite()) {
51                return Err(format!(
52                    "fixture contains a non-finite value at index {index}"
53                ));
54            }
55            if self.high[index] < self.open[index].max(self.close[index]) {
56                return Err(format!(
57                    "fixture high violates OHLC invariants at index {index}"
58                ));
59            }
60            if self.low[index] > self.open[index].min(self.close[index]) {
61                return Err(format!(
62                    "fixture low violates OHLC invariants at index {index}"
63                ));
64            }
65            if self.volume[index] < 0.0 {
66                return Err(format!("fixture volume is negative at index {index}"));
67            }
68        }
69        Ok(())
70    }
71}
72
73pub use crate::fixture::series_fixture;
74
75pub fn catalogue_fixture(size: usize) -> Fixture {
76    let close = series_fixture(size, 0);
77    let auxiliary = series_fixture(size, 2)
78        .into_iter()
79        .enumerate()
80        .map(|(index, value)| value * 0.75 + (index % 13) as f64 * 0.02)
81        .collect::<Vec<_>>();
82    let open = close
83        .iter()
84        .enumerate()
85        .map(|(index, value)| value + ((index % 9) as f64 - 4.0) * 0.035)
86        .collect::<Vec<_>>();
87    let high = open
88        .iter()
89        .zip(&close)
90        .enumerate()
91        .map(|(index, (open, close))| open.max(*close) + 0.5 + (index % 11) as f64 * 0.03)
92        .collect::<Vec<_>>();
93    let low = open
94        .iter()
95        .zip(&close)
96        .enumerate()
97        .map(|(index, (open, close))| open.min(*close) - 0.5 - (index % 7) as f64 * 0.025)
98        .collect::<Vec<_>>();
99    let volume = series_fixture(size, 1)
100        .into_iter()
101        .map(|value| 10_000.0 + value * 100.0)
102        .collect::<Vec<_>>();
103    Fixture {
104        open,
105        high,
106        low,
107        close,
108        volume,
109        auxiliary,
110    }
111}
112
113pub fn input_checksum(values: &[f64]) -> String {
114    let mut hash = 0xcbf29ce484222325_u64;
115    hash_f64s(&mut hash, values);
116    format!("fnv1a64:{hash:016x}")
117}
118
119pub fn fixture_checksum(fixture: &Fixture) -> String {
120    let mut hash = 0xcbf29ce484222325_u64;
121    for (name, values) in [
122        ("open", fixture.open.as_slice()),
123        ("high", fixture.high.as_slice()),
124        ("low", fixture.low.as_slice()),
125        ("close", fixture.close.as_slice()),
126        ("volume", fixture.volume.as_slice()),
127        ("auxiliary", fixture.auxiliary.as_slice()),
128    ] {
129        hash_bytes(&mut hash, name.as_bytes());
130        hash_bytes(&mut hash, &(values.len() as u64).to_le_bytes());
131        hash_f64s(&mut hash, values);
132    }
133    format!("fnv1a64:{hash:016x}")
134}
135
136fn hash_f64s(hash: &mut u64, values: &[f64]) {
137    for value in values {
138        hash_bytes(hash, &value.to_le_bytes());
139    }
140}
141
142fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
143    for byte in bytes {
144        *hash ^= u64::from(*byte);
145        *hash = hash.wrapping_mul(0x100000001b3);
146    }
147}
148
149#[derive(Clone, Debug, PartialEq)]
150pub enum OutputValues {
151    Float(Vec<Vec<f64>>),
152    Integer(Vec<Vec<i32>>),
153}
154
155impl OutputValues {
156    pub fn kind(&self) -> &'static str {
157        match self {
158            Self::Float(_) => "float",
159            Self::Integer(_) => "integer",
160        }
161    }
162
163    pub fn arity(&self) -> usize {
164        match self {
165            Self::Float(columns) => columns.len(),
166            Self::Integer(columns) => columns.len(),
167        }
168    }
169
170    pub fn column_len(&self) -> Result<usize, String> {
171        let lengths = match self {
172            Self::Float(columns) => columns.iter().map(Vec::len).collect::<Vec<_>>(),
173            Self::Integer(columns) => columns.iter().map(Vec::len).collect::<Vec<_>>(),
174        };
175        let Some(&first) = lengths.first() else {
176            return Err("output has no columns".to_owned());
177        };
178        if lengths.iter().any(|length| *length != first) {
179            return Err(format!("output columns have unequal lengths: {lengths:?}"));
180        }
181        Ok(first)
182    }
183}
184
185#[derive(Clone, Debug, PartialEq)]
186pub struct VerifiedOutput {
187    pub begin: usize,
188    pub count: usize,
189    pub values: OutputValues,
190}
191
192impl VerifiedOutput {
193    pub fn validate_shape(&self) -> Result<(), String> {
194        let actual = self.values.column_len()?;
195        if actual != self.count {
196            return Err(format!(
197                "declared output count {} differs from column length {actual}",
198                self.count
199            ));
200        }
201        Ok(())
202    }
203
204    pub fn checksum(&self) -> String {
205        let mut hash = 0xcbf29ce484222325_u64;
206        hash_bytes(&mut hash, self.values.kind().as_bytes());
207        hash_bytes(&mut hash, &(self.begin as u64).to_le_bytes());
208        hash_bytes(&mut hash, &(self.count as u64).to_le_bytes());
209        match &self.values {
210            OutputValues::Float(columns) => {
211                for column in columns {
212                    hash_f64s(&mut hash, column);
213                }
214            }
215            OutputValues::Integer(columns) => {
216                for column in columns {
217                    for value in column {
218                        hash_bytes(&mut hash, &value.to_le_bytes());
219                    }
220                }
221            }
222        }
223        format!("fnv1a64:{hash:016x}")
224    }
225}
226
227pub fn validate_outputs(
228    expected: &VerifiedOutput,
229    implementation: &str,
230    actual: &VerifiedOutput,
231) -> Result<(), String> {
232    expected
233        .validate_shape()
234        .map_err(|error| format!("reference output shape: {error}"))?;
235    actual
236        .validate_shape()
237        .map_err(|error| format!("{implementation} output shape: {error}"))?;
238    if (actual.begin, actual.count) != (expected.begin, expected.count) {
239        return Err(format!(
240            "{implementation} OutputRange mismatch: expected begin {} count {}, got begin {} count {}",
241            expected.begin, expected.count, actual.begin, actual.count
242        ));
243    }
244    if actual.values.kind() != expected.values.kind() {
245        return Err(format!(
246            "{implementation} output kind mismatch: expected {}, got {}",
247            expected.values.kind(),
248            actual.values.kind()
249        ));
250    }
251    if actual.values.arity() != expected.values.arity() {
252        return Err(format!(
253            "{implementation} output arity mismatch: expected {}, got {}",
254            expected.values.arity(),
255            actual.values.arity()
256        ));
257    }
258    match (&expected.values, &actual.values) {
259        (OutputValues::Float(expected_columns), OutputValues::Float(actual_columns)) => {
260            for (column, (expected_values, actual_values)) in
261                expected_columns.iter().zip(actual_columns).enumerate()
262            {
263                for (index, (&expected_value, &actual_value)) in
264                    expected_values.iter().zip(actual_values).enumerate()
265                {
266                    if !expected_value.is_finite() || !actual_value.is_finite() {
267                        if expected_value.to_bits() != actual_value.to_bits() {
268                            return Err(format!("{implementation} non-finite placement mismatch at column {column} compact index {index}: expected {expected_value:?}, got {actual_value:?}"));
269                        }
270                        continue;
271                    }
272                    let difference = (actual_value - expected_value).abs();
273                    let tolerance = ABS_TOLERANCE.max(REL_TOLERANCE * expected_value.abs());
274                    if difference > tolerance {
275                        return Err(format!("{implementation} value mismatch at column {column} compact index {index}: expected {expected_value:.17e}, got {actual_value:.17e}, difference {difference:.3e}, tolerance {tolerance:.3e}"));
276                    }
277                }
278            }
279        }
280        (OutputValues::Integer(expected_columns), OutputValues::Integer(actual_columns)) => {
281            for (column, (expected_values, actual_values)) in
282                expected_columns.iter().zip(actual_columns).enumerate()
283            {
284                for (index, (&expected_value, &actual_value)) in
285                    expected_values.iter().zip(actual_values).enumerate()
286                {
287                    if actual_value != expected_value {
288                        return Err(format!("{implementation} exact integer mismatch at column {column} compact index {index}: expected {expected_value}, got {actual_value}"));
289                    }
290                }
291            }
292        }
293        _ => unreachable!("output kinds were checked above"),
294    }
295    Ok(())
296}