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 #[serde(default = "default_prevent_same_timestamp_close")]
98 pub prevent_same_timestamp_close: bool,
99
100 #[serde(default)]
106 pub defer_open: bool,
107}
108
109fn default_prevent_same_timestamp_close() -> bool {
111 true
112}
113
114impl Checkpoint {
115 #[allow(clippy::too_many_arguments)]
117 pub fn new(
118 symbol: String,
119 threshold_decimal_bps: u32,
120 incomplete_bar: Option<RangeBar>,
121 thresholds: Option<(FixedPoint, FixedPoint)>,
122 last_timestamp_us: i64,
123 last_trade_id: Option<i64>,
124 price_hash: u64,
125 prevent_same_timestamp_close: bool,
126 ) -> Self {
127 Self {
128 symbol,
129 threshold_decimal_bps,
130 incomplete_bar,
131 thresholds,
132 last_timestamp_us,
133 last_trade_id,
134 price_hash,
135 anomaly_summary: AnomalySummary::default(),
136 prevent_same_timestamp_close,
137 defer_open: false,
138 }
139 }
140
141 pub fn has_incomplete_bar(&self) -> bool {
143 self.incomplete_bar.is_some()
144 }
145
146 pub fn library_version() -> &'static str {
148 env!("CARGO_PKG_VERSION")
149 }
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
157pub struct AnomalySummary {
158 pub gaps_detected: u32,
160
161 pub overlaps_detected: u32,
163
164 pub timestamp_anomalies: u32,
166}
167
168impl AnomalySummary {
169 pub fn record_gap(&mut self) {
171 self.gaps_detected += 1;
172 }
173
174 pub fn record_overlap(&mut self) {
176 self.overlaps_detected += 1;
177 }
178
179 pub fn record_timestamp_anomaly(&mut self) {
181 self.timestamp_anomalies += 1;
182 }
183
184 pub fn has_anomalies(&self) -> bool {
186 self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
187 }
188
189 pub fn total(&self) -> u32 {
191 self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
192 }
193}
194
195#[derive(Debug, Clone, PartialEq)]
197pub enum PositionVerification {
198 Exact,
200
201 Gap {
204 expected_id: i64,
205 actual_id: i64,
206 missing_count: i64,
207 },
208
209 TimestampOnly { gap_ms: i64 },
212}
213
214impl PositionVerification {
215 pub fn has_gap(&self) -> bool {
217 matches!(self, PositionVerification::Gap { .. })
218 }
219}
220
221#[derive(Error, Debug, Clone, PartialEq)]
223pub enum CheckpointError {
224 #[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
226 SymbolMismatch {
227 checkpoint: String,
228 expected: String,
229 },
230
231 #[error("Threshold mismatch: checkpoint has {checkpoint} dbps, expected {expected} dbps")]
233 ThresholdMismatch { checkpoint: u32, expected: u32 },
234
235 #[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
237 PriceHashMismatch { checkpoint: u64, computed: u64 },
238
239 #[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
241 MissingThresholds,
242
243 #[error("Checkpoint serialization error: {message}")]
245 SerializationError { message: String },
246
247 #[error(
249 "Invalid threshold in checkpoint: {threshold} dbps. Valid range: {min_threshold}-{max_threshold} dbps"
250 )]
251 InvalidThreshold {
252 threshold: u32,
253 min_threshold: u32,
254 max_threshold: u32,
255 },
256}
257
258#[derive(Debug, Clone)]
262pub struct PriceWindow {
263 prices: [i64; PRICE_WINDOW_SIZE],
264 index: usize,
265 count: usize,
266}
267
268impl Default for PriceWindow {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274impl PriceWindow {
275 pub fn new() -> Self {
277 Self {
278 prices: [0; PRICE_WINDOW_SIZE],
279 index: 0,
280 count: 0,
281 }
282 }
283
284 pub fn push(&mut self, price: FixedPoint) {
286 self.prices[self.index] = price.0;
287 self.index = (self.index + 1) % PRICE_WINDOW_SIZE;
288 if self.count < PRICE_WINDOW_SIZE {
289 self.count += 1;
290 }
291 }
292
293 pub fn compute_hash(&self) -> u64 {
297 let mut hasher = AHasher::default();
298
299 if self.count < PRICE_WINDOW_SIZE {
301 for i in 0..self.count {
303 hasher.write_i64(self.prices[i]);
304 }
305 } else {
306 for i in 0..PRICE_WINDOW_SIZE {
308 let idx = (self.index + i) % PRICE_WINDOW_SIZE;
309 hasher.write_i64(self.prices[idx]);
310 }
311 }
312
313 hasher.finish()
314 }
315
316 pub fn len(&self) -> usize {
318 self.count
319 }
320
321 pub fn is_empty(&self) -> bool {
323 self.count == 0
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_checkpoint_creation() {
333 let checkpoint = Checkpoint::new(
334 "BTCUSDT".to_string(),
335 250, None,
337 None,
338 1640995200000000, Some(12345),
340 0,
341 true, );
343
344 assert_eq!(checkpoint.symbol, "BTCUSDT");
345 assert_eq!(checkpoint.threshold_decimal_bps, 250);
346 assert!(!checkpoint.has_incomplete_bar());
347 assert_eq!(checkpoint.last_trade_id, Some(12345));
348 assert!(checkpoint.prevent_same_timestamp_close);
349 }
350
351 #[test]
352 fn test_checkpoint_serialization() {
353 let checkpoint = Checkpoint::new(
354 "EURUSD".to_string(),
355 10, None,
357 None,
358 1640995200000000,
359 None, 12345678,
361 true, );
363
364 let json = serde_json::to_string(&checkpoint).unwrap();
366 assert!(json.contains("EURUSD"));
367 assert!(json.contains("\"threshold_decimal_bps\":10"));
368 assert!(json.contains("\"prevent_same_timestamp_close\":true"));
369
370 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
372 assert_eq!(restored.symbol, "EURUSD");
373 assert_eq!(restored.threshold_decimal_bps, 10);
374 assert_eq!(restored.price_hash, 12345678);
375 assert!(restored.prevent_same_timestamp_close);
376 }
377
378 #[test]
379 fn test_checkpoint_serialization_toggle_false() {
380 let checkpoint = Checkpoint::new(
381 "BTCUSDT".to_string(),
382 100, None,
384 None,
385 1640995200000000,
386 Some(999),
387 12345678,
388 false, );
390
391 let json = serde_json::to_string(&checkpoint).unwrap();
393 assert!(json.contains("\"prevent_same_timestamp_close\":false"));
394
395 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
397 assert!(!restored.prevent_same_timestamp_close);
398 }
399
400 #[test]
401 fn test_checkpoint_deserialization_default() {
402 let json = r#"{
404 "symbol": "BTCUSDT",
405 "threshold_decimal_bps": 100,
406 "incomplete_bar": null,
407 "thresholds": null,
408 "last_timestamp_us": 1640995200000000,
409 "last_trade_id": 12345,
410 "price_hash": 0,
411 "anomaly_summary": {"gaps_detected": 0, "overlaps_detected": 0, "timestamp_anomalies": 0}
412 }"#;
413
414 let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
415 assert!(checkpoint.prevent_same_timestamp_close);
417 }
418
419 #[test]
420 fn test_anomaly_summary() {
421 let mut summary = AnomalySummary::default();
422 assert!(!summary.has_anomalies());
423 assert_eq!(summary.total(), 0);
424
425 summary.record_gap();
426 summary.record_gap();
427 summary.record_timestamp_anomaly();
428
429 assert!(summary.has_anomalies());
430 assert_eq!(summary.gaps_detected, 2);
431 assert_eq!(summary.timestamp_anomalies, 1);
432 assert_eq!(summary.total(), 3);
433 }
434
435 #[test]
436 fn test_price_window() {
437 let mut window = PriceWindow::new();
438 assert!(window.is_empty());
439
440 window.push(FixedPoint(5000000000000)); window.push(FixedPoint(5001000000000)); window.push(FixedPoint(5002000000000)); assert_eq!(window.len(), 3);
446 assert!(!window.is_empty());
447
448 let hash1 = window.compute_hash();
449
450 let mut window2 = PriceWindow::new();
452 window2.push(FixedPoint(5000000000000));
453 window2.push(FixedPoint(5001000000000));
454 window2.push(FixedPoint(5002000000000));
455
456 let hash2 = window2.compute_hash();
457 assert_eq!(hash1, hash2);
458
459 let mut window3 = PriceWindow::new();
461 window3.push(FixedPoint(5000000000000));
462 window3.push(FixedPoint(5001000000000));
463 window3.push(FixedPoint(5003000000000)); let hash3 = window3.compute_hash();
466 assert_ne!(hash1, hash3);
467 }
468
469 #[test]
470 fn test_price_window_circular() {
471 let mut window = PriceWindow::new();
472
473 for i in 0..12 {
475 window.push(FixedPoint(i * 100000000));
476 }
477
478 assert_eq!(window.len(), PRICE_WINDOW_SIZE);
480
481 let hash1 = window.compute_hash();
483 let hash2 = window.compute_hash();
484 assert_eq!(hash1, hash2);
485 }
486
487 #[test]
488 fn test_position_verification() {
489 let exact = PositionVerification::Exact;
490 assert!(!exact.has_gap());
491
492 let gap = PositionVerification::Gap {
493 expected_id: 100,
494 actual_id: 105,
495 missing_count: 5,
496 };
497 assert!(gap.has_gap());
498
499 let timestamp_only = PositionVerification::TimestampOnly { gap_ms: 1000 };
500 assert!(!timestamp_only.has_gap());
501 }
502
503 #[test]
504 fn test_library_version() {
505 let version = Checkpoint::library_version();
506 assert!(version.contains('.'));
508 println!("Library version: {}", version);
509 }
510}