1use crate::fixed_point::FixedPoint;
22use crate::types::RangeBar;
23use ahash::AHasher;
24use serde::{Deserialize, Serialize};
25use std::hash::Hasher;
26use thiserror::Error;
27
28const PRICE_WINDOW_SIZE: usize = 8;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Checkpoint {
55 pub symbol: String,
58
59 pub threshold_decimal_bps: u32,
62
63 pub incomplete_bar: Option<RangeBar>,
67
68 pub thresholds: Option<(FixedPoint, FixedPoint)>,
71
72 pub last_timestamp_us: i64,
75
76 pub last_trade_id: Option<i64>,
79
80 pub price_hash: u64,
84
85 pub anomaly_summary: AnomalySummary,
88}
89
90impl Checkpoint {
91 pub fn new(
93 symbol: String,
94 threshold_decimal_bps: u32,
95 incomplete_bar: Option<RangeBar>,
96 thresholds: Option<(FixedPoint, FixedPoint)>,
97 last_timestamp_us: i64,
98 last_trade_id: Option<i64>,
99 price_hash: u64,
100 ) -> Self {
101 Self {
102 symbol,
103 threshold_decimal_bps,
104 incomplete_bar,
105 thresholds,
106 last_timestamp_us,
107 last_trade_id,
108 price_hash,
109 anomaly_summary: AnomalySummary::default(),
110 }
111 }
112
113 pub fn has_incomplete_bar(&self) -> bool {
115 self.incomplete_bar.is_some()
116 }
117
118 pub fn library_version() -> &'static str {
120 env!("CARGO_PKG_VERSION")
121 }
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
129pub struct AnomalySummary {
130 pub gaps_detected: u32,
132
133 pub overlaps_detected: u32,
135
136 pub timestamp_anomalies: u32,
138}
139
140impl AnomalySummary {
141 pub fn record_gap(&mut self) {
143 self.gaps_detected += 1;
144 }
145
146 pub fn record_overlap(&mut self) {
148 self.overlaps_detected += 1;
149 }
150
151 pub fn record_timestamp_anomaly(&mut self) {
153 self.timestamp_anomalies += 1;
154 }
155
156 pub fn has_anomalies(&self) -> bool {
158 self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
159 }
160
161 pub fn total(&self) -> u32 {
163 self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
164 }
165}
166
167#[derive(Debug, Clone, PartialEq)]
169pub enum PositionVerification {
170 Exact,
172
173 Gap {
176 expected_id: i64,
177 actual_id: i64,
178 missing_count: i64,
179 },
180
181 TimestampOnly { gap_ms: i64 },
184}
185
186impl PositionVerification {
187 pub fn has_gap(&self) -> bool {
189 matches!(self, PositionVerification::Gap { .. })
190 }
191}
192
193#[derive(Error, Debug, Clone, PartialEq)]
195pub enum CheckpointError {
196 #[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
198 SymbolMismatch {
199 checkpoint: String,
200 expected: String,
201 },
202
203 #[error(
205 "Threshold mismatch: checkpoint has {checkpoint} decimal bps, expected {expected} decimal bps"
206 )]
207 ThresholdMismatch { checkpoint: u32, expected: u32 },
208
209 #[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
211 PriceHashMismatch { checkpoint: u64, computed: u64 },
212
213 #[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
215 MissingThresholds,
216
217 #[error("Checkpoint serialization error: {message}")]
219 SerializationError { message: String },
220}
221
222#[derive(Debug, Clone)]
226pub struct PriceWindow {
227 prices: [i64; PRICE_WINDOW_SIZE],
228 index: usize,
229 count: usize,
230}
231
232impl Default for PriceWindow {
233 fn default() -> Self {
234 Self::new()
235 }
236}
237
238impl PriceWindow {
239 pub fn new() -> Self {
241 Self {
242 prices: [0; PRICE_WINDOW_SIZE],
243 index: 0,
244 count: 0,
245 }
246 }
247
248 pub fn push(&mut self, price: FixedPoint) {
250 self.prices[self.index] = price.0;
251 self.index = (self.index + 1) % PRICE_WINDOW_SIZE;
252 if self.count < PRICE_WINDOW_SIZE {
253 self.count += 1;
254 }
255 }
256
257 pub fn compute_hash(&self) -> u64 {
261 let mut hasher = AHasher::default();
262
263 if self.count < PRICE_WINDOW_SIZE {
265 for i in 0..self.count {
267 hasher.write_i64(self.prices[i]);
268 }
269 } else {
270 for i in 0..PRICE_WINDOW_SIZE {
272 let idx = (self.index + i) % PRICE_WINDOW_SIZE;
273 hasher.write_i64(self.prices[idx]);
274 }
275 }
276
277 hasher.finish()
278 }
279
280 pub fn len(&self) -> usize {
282 self.count
283 }
284
285 pub fn is_empty(&self) -> bool {
287 self.count == 0
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_checkpoint_creation() {
297 let checkpoint = Checkpoint::new(
298 "BTCUSDT".to_string(),
299 250, None,
301 None,
302 1640995200000000, Some(12345),
304 0,
305 );
306
307 assert_eq!(checkpoint.symbol, "BTCUSDT");
308 assert_eq!(checkpoint.threshold_decimal_bps, 250);
309 assert!(!checkpoint.has_incomplete_bar());
310 assert_eq!(checkpoint.last_trade_id, Some(12345));
311 }
312
313 #[test]
314 fn test_checkpoint_serialization() {
315 let checkpoint = Checkpoint::new(
316 "EURUSD".to_string(),
317 10, None,
319 None,
320 1640995200000000,
321 None, 12345678,
323 );
324
325 let json = serde_json::to_string(&checkpoint).unwrap();
327 assert!(json.contains("EURUSD"));
328 assert!(json.contains("\"threshold_decimal_bps\":10"));
329
330 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
332 assert_eq!(restored.symbol, "EURUSD");
333 assert_eq!(restored.threshold_decimal_bps, 10);
334 assert_eq!(restored.price_hash, 12345678);
335 }
336
337 #[test]
338 fn test_anomaly_summary() {
339 let mut summary = AnomalySummary::default();
340 assert!(!summary.has_anomalies());
341 assert_eq!(summary.total(), 0);
342
343 summary.record_gap();
344 summary.record_gap();
345 summary.record_timestamp_anomaly();
346
347 assert!(summary.has_anomalies());
348 assert_eq!(summary.gaps_detected, 2);
349 assert_eq!(summary.timestamp_anomalies, 1);
350 assert_eq!(summary.total(), 3);
351 }
352
353 #[test]
354 fn test_price_window() {
355 let mut window = PriceWindow::new();
356 assert!(window.is_empty());
357
358 window.push(FixedPoint(5000000000000)); window.push(FixedPoint(5001000000000)); window.push(FixedPoint(5002000000000)); assert_eq!(window.len(), 3);
364 assert!(!window.is_empty());
365
366 let hash1 = window.compute_hash();
367
368 let mut window2 = PriceWindow::new();
370 window2.push(FixedPoint(5000000000000));
371 window2.push(FixedPoint(5001000000000));
372 window2.push(FixedPoint(5002000000000));
373
374 let hash2 = window2.compute_hash();
375 assert_eq!(hash1, hash2);
376
377 let mut window3 = PriceWindow::new();
379 window3.push(FixedPoint(5000000000000));
380 window3.push(FixedPoint(5001000000000));
381 window3.push(FixedPoint(5003000000000)); let hash3 = window3.compute_hash();
384 assert_ne!(hash1, hash3);
385 }
386
387 #[test]
388 fn test_price_window_circular() {
389 let mut window = PriceWindow::new();
390
391 for i in 0..12 {
393 window.push(FixedPoint(i * 100000000));
394 }
395
396 assert_eq!(window.len(), PRICE_WINDOW_SIZE);
398
399 let hash1 = window.compute_hash();
401 let hash2 = window.compute_hash();
402 assert_eq!(hash1, hash2);
403 }
404
405 #[test]
406 fn test_position_verification() {
407 let exact = PositionVerification::Exact;
408 assert!(!exact.has_gap());
409
410 let gap = PositionVerification::Gap {
411 expected_id: 100,
412 actual_id: 105,
413 missing_count: 5,
414 };
415 assert!(gap.has_gap());
416
417 let timestamp_only = PositionVerification::TimestampOnly { gap_ms: 1000 };
418 assert!(!timestamp_only.has_gap());
419 }
420
421 #[test]
422 fn test_library_version() {
423 let version = Checkpoint::library_version();
424 assert!(version.contains('.'));
426 println!("Library version: {}", version);
427 }
428}