1use crate::fixed_point::FixedPoint;
20use crate::types::RangeBar;
21use ahash::AHasher;
22use serde::{Deserialize, Serialize};
23use std::hash::Hasher;
24use thiserror::Error;
25
26const PRICE_WINDOW_SIZE: usize = 8;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Checkpoint {
53 pub symbol: String,
56
57 pub threshold_decimal_bps: u32,
60
61 pub incomplete_bar: Option<RangeBar>,
65
66 pub thresholds: Option<(FixedPoint, FixedPoint)>,
69
70 pub last_timestamp_us: i64,
73
74 pub last_trade_id: Option<i64>,
77
78 pub price_hash: u64,
82
83 pub anomaly_summary: AnomalySummary,
86}
87
88impl Checkpoint {
89 pub fn new(
91 symbol: String,
92 threshold_decimal_bps: u32,
93 incomplete_bar: Option<RangeBar>,
94 thresholds: Option<(FixedPoint, FixedPoint)>,
95 last_timestamp_us: i64,
96 last_trade_id: Option<i64>,
97 price_hash: u64,
98 ) -> Self {
99 Self {
100 symbol,
101 threshold_decimal_bps,
102 incomplete_bar,
103 thresholds,
104 last_timestamp_us,
105 last_trade_id,
106 price_hash,
107 anomaly_summary: AnomalySummary::default(),
108 }
109 }
110
111 pub fn has_incomplete_bar(&self) -> bool {
113 self.incomplete_bar.is_some()
114 }
115
116 pub fn library_version() -> &'static str {
118 env!("CARGO_PKG_VERSION")
119 }
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
127pub struct AnomalySummary {
128 pub gaps_detected: u32,
130
131 pub overlaps_detected: u32,
133
134 pub timestamp_anomalies: u32,
136}
137
138impl AnomalySummary {
139 pub fn record_gap(&mut self) {
141 self.gaps_detected += 1;
142 }
143
144 pub fn record_overlap(&mut self) {
146 self.overlaps_detected += 1;
147 }
148
149 pub fn record_timestamp_anomaly(&mut self) {
151 self.timestamp_anomalies += 1;
152 }
153
154 pub fn has_anomalies(&self) -> bool {
156 self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
157 }
158
159 pub fn total(&self) -> u32 {
161 self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
162 }
163}
164
165#[derive(Debug, Clone, PartialEq)]
167pub enum PositionVerification {
168 Exact,
170
171 Gap {
174 expected_id: i64,
175 actual_id: i64,
176 missing_count: i64,
177 },
178
179 TimestampOnly { gap_ms: i64 },
182}
183
184impl PositionVerification {
185 pub fn has_gap(&self) -> bool {
187 matches!(self, PositionVerification::Gap { .. })
188 }
189}
190
191#[derive(Error, Debug, Clone, PartialEq)]
193pub enum CheckpointError {
194 #[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
196 SymbolMismatch {
197 checkpoint: String,
198 expected: String,
199 },
200
201 #[error(
203 "Threshold mismatch: checkpoint has {checkpoint} decimal bps, expected {expected} decimal bps"
204 )]
205 ThresholdMismatch { checkpoint: u32, expected: u32 },
206
207 #[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
209 PriceHashMismatch { checkpoint: u64, computed: u64 },
210
211 #[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
213 MissingThresholds,
214
215 #[error("Checkpoint serialization error: {message}")]
217 SerializationError { message: String },
218}
219
220#[derive(Debug, Clone)]
224pub struct PriceWindow {
225 prices: [i64; PRICE_WINDOW_SIZE],
226 index: usize,
227 count: usize,
228}
229
230impl Default for PriceWindow {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236impl PriceWindow {
237 pub fn new() -> Self {
239 Self {
240 prices: [0; PRICE_WINDOW_SIZE],
241 index: 0,
242 count: 0,
243 }
244 }
245
246 pub fn push(&mut self, price: FixedPoint) {
248 self.prices[self.index] = price.0;
249 self.index = (self.index + 1) % PRICE_WINDOW_SIZE;
250 if self.count < PRICE_WINDOW_SIZE {
251 self.count += 1;
252 }
253 }
254
255 pub fn compute_hash(&self) -> u64 {
259 let mut hasher = AHasher::default();
260
261 if self.count < PRICE_WINDOW_SIZE {
263 for i in 0..self.count {
265 hasher.write_i64(self.prices[i]);
266 }
267 } else {
268 for i in 0..PRICE_WINDOW_SIZE {
270 let idx = (self.index + i) % PRICE_WINDOW_SIZE;
271 hasher.write_i64(self.prices[idx]);
272 }
273 }
274
275 hasher.finish()
276 }
277
278 pub fn len(&self) -> usize {
280 self.count
281 }
282
283 pub fn is_empty(&self) -> bool {
285 self.count == 0
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_checkpoint_creation() {
295 let checkpoint = Checkpoint::new(
296 "BTCUSDT".to_string(),
297 250, None,
299 None,
300 1640995200000000, Some(12345),
302 0,
303 );
304
305 assert_eq!(checkpoint.symbol, "BTCUSDT");
306 assert_eq!(checkpoint.threshold_decimal_bps, 250);
307 assert!(!checkpoint.has_incomplete_bar());
308 assert_eq!(checkpoint.last_trade_id, Some(12345));
309 }
310
311 #[test]
312 fn test_checkpoint_serialization() {
313 let checkpoint = Checkpoint::new(
314 "EURUSD".to_string(),
315 10, None,
317 None,
318 1640995200000000,
319 None, 12345678,
321 );
322
323 let json = serde_json::to_string(&checkpoint).unwrap();
325 assert!(json.contains("EURUSD"));
326 assert!(json.contains("\"threshold_decimal_bps\":10"));
327
328 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
330 assert_eq!(restored.symbol, "EURUSD");
331 assert_eq!(restored.threshold_decimal_bps, 10);
332 assert_eq!(restored.price_hash, 12345678);
333 }
334
335 #[test]
336 fn test_anomaly_summary() {
337 let mut summary = AnomalySummary::default();
338 assert!(!summary.has_anomalies());
339 assert_eq!(summary.total(), 0);
340
341 summary.record_gap();
342 summary.record_gap();
343 summary.record_timestamp_anomaly();
344
345 assert!(summary.has_anomalies());
346 assert_eq!(summary.gaps_detected, 2);
347 assert_eq!(summary.timestamp_anomalies, 1);
348 assert_eq!(summary.total(), 3);
349 }
350
351 #[test]
352 fn test_price_window() {
353 let mut window = PriceWindow::new();
354 assert!(window.is_empty());
355
356 window.push(FixedPoint(5000000000000)); window.push(FixedPoint(5001000000000)); window.push(FixedPoint(5002000000000)); assert_eq!(window.len(), 3);
362 assert!(!window.is_empty());
363
364 let hash1 = window.compute_hash();
365
366 let mut window2 = PriceWindow::new();
368 window2.push(FixedPoint(5000000000000));
369 window2.push(FixedPoint(5001000000000));
370 window2.push(FixedPoint(5002000000000));
371
372 let hash2 = window2.compute_hash();
373 assert_eq!(hash1, hash2);
374
375 let mut window3 = PriceWindow::new();
377 window3.push(FixedPoint(5000000000000));
378 window3.push(FixedPoint(5001000000000));
379 window3.push(FixedPoint(5003000000000)); let hash3 = window3.compute_hash();
382 assert_ne!(hash1, hash3);
383 }
384
385 #[test]
386 fn test_price_window_circular() {
387 let mut window = PriceWindow::new();
388
389 for i in 0..12 {
391 window.push(FixedPoint(i * 100000000));
392 }
393
394 assert_eq!(window.len(), PRICE_WINDOW_SIZE);
396
397 let hash1 = window.compute_hash();
399 let hash2 = window.compute_hash();
400 assert_eq!(hash1, hash2);
401 }
402
403 #[test]
404 fn test_position_verification() {
405 let exact = PositionVerification::Exact;
406 assert!(!exact.has_gap());
407
408 let gap = PositionVerification::Gap {
409 expected_id: 100,
410 actual_id: 105,
411 missing_count: 5,
412 };
413 assert!(gap.has_gap());
414
415 let timestamp_only = PositionVerification::TimestampOnly { gap_ms: 1000 };
416 assert!(!timestamp_only.has_gap());
417 }
418
419 #[test]
420 fn test_library_version() {
421 let version = Checkpoint::library_version();
422 assert!(version.contains('.'));
424 println!("Library version: {}", version);
425 }
426}