1use crate::fixed_point::FixedPoint;
22use crate::types::OpenDeviationBar;
23use foldhash::fast::FixedState;
24use serde::{Deserialize, Serialize};
25use std::hash::{BuildHasher, Hasher};
26use thiserror::Error;
27
28const PRICE_WINDOW_SIZE: usize = 8;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Checkpoint {
55 #[serde(default = "default_checkpoint_version")]
60 pub version: u32,
61
62 pub symbol: String,
65
66 pub threshold_decimal_bps: u32,
69
70 pub incomplete_bar: Option<OpenDeviationBar>,
74
75 pub thresholds: Option<(FixedPoint, FixedPoint)>,
78
79 pub last_timestamp_us: i64,
82
83 pub last_trade_id: Option<i64>,
86
87 pub price_hash: u64,
91
92 pub anomaly_summary: AnomalySummary,
95
96 #[serde(default = "default_prevent_same_timestamp_close")]
105 pub prevent_same_timestamp_close: bool,
106
107 #[serde(default)]
113 pub defer_open: bool,
114
115 #[serde(default)]
122 pub last_completed_bar_tid: Option<i64>,
123}
124
125fn default_checkpoint_version() -> u32 {
127 1
128}
129
130fn default_prevent_same_timestamp_close() -> bool {
132 true
133}
134
135impl Checkpoint {
136 pub fn new(
138 symbol: String,
139 threshold_decimal_bps: u32,
140 incomplete_bar: Option<OpenDeviationBar>,
141 thresholds: Option<(FixedPoint, FixedPoint)>,
142 last_timestamp_us: i64,
143 last_trade_id: Option<i64>,
144 price_hash: u64,
145 prevent_same_timestamp_close: bool,
146 ) -> Self {
147 Self {
148 version: 2, symbol,
150 threshold_decimal_bps,
151 incomplete_bar,
152 thresholds,
153 last_timestamp_us,
154 last_trade_id,
155 price_hash,
156 anomaly_summary: AnomalySummary::default(),
157 prevent_same_timestamp_close,
158 defer_open: false,
159 last_completed_bar_tid: None,
160 }
161 }
162
163 pub fn has_incomplete_bar(&self) -> bool {
165 self.incomplete_bar.is_some()
166 }
167
168 pub fn library_version() -> &'static str {
170 env!("CARGO_PKG_VERSION")
171 }
172}
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
179pub struct AnomalySummary {
180 pub gaps_detected: u32,
182
183 pub overlaps_detected: u32,
185
186 pub timestamp_anomalies: u32,
188}
189
190impl AnomalySummary {
191 pub fn record_gap(&mut self) {
193 self.gaps_detected += 1;
194 }
195
196 pub fn record_overlap(&mut self) {
198 self.overlaps_detected += 1;
199 }
200
201 pub fn record_timestamp_anomaly(&mut self) {
203 self.timestamp_anomalies += 1;
204 }
205
206 pub fn has_anomalies(&self) -> bool {
208 self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
209 }
210
211 pub fn total(&self) -> u32 {
213 self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
214 }
215}
216
217#[derive(Debug, Clone, PartialEq)]
219pub enum PositionVerification {
220 Exact,
222
223 Gap {
226 expected_id: i64,
227 actual_id: i64,
228 missing_count: i64,
229 },
230
231 TimestampOnly { gap_ms: i64 },
234}
235
236impl PositionVerification {
237 pub fn has_gap(&self) -> bool {
239 matches!(self, PositionVerification::Gap { .. })
240 }
241}
242
243#[derive(Error, Debug, Clone, PartialEq)]
245pub enum CheckpointError {
246 #[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
248 SymbolMismatch {
249 checkpoint: String,
250 expected: String,
251 },
252
253 #[error("Threshold mismatch: checkpoint has {checkpoint} dbps, expected {expected} dbps")]
255 ThresholdMismatch { checkpoint: u32, expected: u32 },
256
257 #[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
259 PriceHashMismatch { checkpoint: u64, computed: u64 },
260
261 #[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
263 MissingThresholds,
264
265 #[error("Checkpoint serialization error: {message}")]
267 SerializationError { message: String },
268
269 #[error(
271 "Invalid threshold in checkpoint: {threshold} dbps. Valid range: {min_threshold}-{max_threshold} dbps"
272 )]
273 InvalidThreshold {
274 threshold: u32,
275 min_threshold: u32,
276 max_threshold: u32,
277 },
278}
279
280#[derive(Debug, Clone)]
284pub struct PriceWindow {
285 prices: [i64; PRICE_WINDOW_SIZE],
286 index: usize,
287 count: usize,
288}
289
290impl Default for PriceWindow {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296impl PriceWindow {
297 pub fn new() -> Self {
299 Self {
300 prices: [0; PRICE_WINDOW_SIZE],
301 index: 0,
302 count: 0,
303 }
304 }
305
306 pub fn push(&mut self, price: FixedPoint) {
308 self.prices[self.index] = price.0;
309 self.index = (self.index + 1) % PRICE_WINDOW_SIZE;
310 if self.count < PRICE_WINDOW_SIZE {
311 self.count += 1;
312 }
313 }
314
315 pub fn compute_hash(&self) -> u64 {
319 let mut hasher = FixedState::default().build_hasher();
320
321 if self.count < PRICE_WINDOW_SIZE {
323 for i in 0..self.count {
325 hasher.write_i64(self.prices[i]);
326 }
327 } else {
328 for i in 0..PRICE_WINDOW_SIZE {
330 let idx = (self.index + i) % PRICE_WINDOW_SIZE;
331 hasher.write_i64(self.prices[idx]);
332 }
333 }
334
335 hasher.finish()
336 }
337
338 pub fn len(&self) -> usize {
340 self.count
341 }
342
343 pub fn is_empty(&self) -> bool {
345 self.count == 0
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_checkpoint_creation() {
355 let checkpoint = Checkpoint::new(
356 "BTCUSDT".to_string(),
357 250, None,
359 None,
360 1640995200000000, Some(12345),
362 0,
363 true, );
365
366 assert_eq!(checkpoint.symbol, "BTCUSDT");
367 assert_eq!(checkpoint.threshold_decimal_bps, 250);
368 assert!(!checkpoint.has_incomplete_bar());
369 assert_eq!(checkpoint.last_trade_id, Some(12345));
370 assert!(checkpoint.prevent_same_timestamp_close);
371 }
372
373 #[test]
374 fn test_checkpoint_serialization() {
375 let checkpoint = Checkpoint::new(
376 "EURUSD".to_string(),
377 10, None,
379 None,
380 1640995200000000,
381 None, 12345678,
383 true, );
385
386 let json = serde_json::to_string(&checkpoint).unwrap();
388 assert!(json.contains("EURUSD"));
389 assert!(json.contains("\"threshold_decimal_bps\":10"));
390 assert!(json.contains("\"prevent_same_timestamp_close\":true"));
391
392 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
394 assert_eq!(restored.symbol, "EURUSD");
395 assert_eq!(restored.threshold_decimal_bps, 10);
396 assert_eq!(restored.price_hash, 12345678);
397 assert!(restored.prevent_same_timestamp_close);
398 }
399
400 #[test]
401 fn test_checkpoint_serialization_toggle_false() {
402 let checkpoint = Checkpoint::new(
403 "BTCUSDT".to_string(),
404 100, None,
406 None,
407 1640995200000000,
408 Some(999),
409 12345678,
410 false, );
412
413 let json = serde_json::to_string(&checkpoint).unwrap();
415 assert!(json.contains("\"prevent_same_timestamp_close\":false"));
416
417 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
419 assert!(!restored.prevent_same_timestamp_close);
420 }
421
422 #[test]
423 fn test_checkpoint_deserialization_default() {
424 let json = r#"{
426 "symbol": "BTCUSDT",
427 "threshold_decimal_bps": 100,
428 "incomplete_bar": null,
429 "thresholds": null,
430 "last_timestamp_us": 1640995200000000,
431 "last_trade_id": 12345,
432 "price_hash": 0,
433 "anomaly_summary": {"gaps_detected": 0, "overlaps_detected": 0, "timestamp_anomalies": 0}
434 }"#;
435
436 let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
437 assert!(checkpoint.prevent_same_timestamp_close);
439 }
440
441 #[test]
442 fn test_anomaly_summary() {
443 let mut summary = AnomalySummary::default();
444 assert!(!summary.has_anomalies());
445 assert_eq!(summary.total(), 0);
446
447 summary.record_gap();
448 summary.record_gap();
449 summary.record_timestamp_anomaly();
450
451 assert!(summary.has_anomalies());
452 assert_eq!(summary.gaps_detected, 2);
453 assert_eq!(summary.timestamp_anomalies, 1);
454 assert_eq!(summary.total(), 3);
455 }
456
457 #[test]
458 fn test_price_window() {
459 let mut window = PriceWindow::new();
460 assert!(window.is_empty());
461
462 window.push(FixedPoint(5000000000000)); window.push(FixedPoint(5001000000000)); window.push(FixedPoint(5002000000000)); assert_eq!(window.len(), 3);
468 assert!(!window.is_empty());
469
470 let hash1 = window.compute_hash();
471
472 let mut window2 = PriceWindow::new();
474 window2.push(FixedPoint(5000000000000));
475 window2.push(FixedPoint(5001000000000));
476 window2.push(FixedPoint(5002000000000));
477
478 let hash2 = window2.compute_hash();
479 assert_eq!(hash1, hash2);
480
481 let mut window3 = PriceWindow::new();
483 window3.push(FixedPoint(5000000000000));
484 window3.push(FixedPoint(5001000000000));
485 window3.push(FixedPoint(5003000000000)); let hash3 = window3.compute_hash();
488 assert_ne!(hash1, hash3);
489 }
490
491 #[test]
492 fn test_price_window_circular() {
493 let mut window = PriceWindow::new();
494
495 for i in 0..12 {
497 window.push(FixedPoint(i * 100000000));
498 }
499
500 assert_eq!(window.len(), PRICE_WINDOW_SIZE);
502
503 let hash1 = window.compute_hash();
505 let hash2 = window.compute_hash();
506 assert_eq!(hash1, hash2);
507 }
508
509 #[test]
510 fn test_position_verification() {
511 let exact = PositionVerification::Exact;
512 assert!(!exact.has_gap());
513
514 let gap = PositionVerification::Gap {
515 expected_id: 100,
516 actual_id: 105,
517 missing_count: 5,
518 };
519 assert!(gap.has_gap());
520
521 let timestamp_only = PositionVerification::TimestampOnly { gap_ms: 1000 };
522 assert!(!timestamp_only.has_gap());
523 }
524
525 #[test]
526 fn test_library_version() {
527 let version = Checkpoint::library_version();
528 assert!(version.contains('.'));
530 println!("Library version: {}", version);
531 }
532
533 #[test]
534 fn test_checkpoint_versioning() {
535 let checkpoint = Checkpoint::new(
536 "BTCUSDT".to_string(),
537 250, None,
539 None,
540 1640995200000000,
541 Some(12345),
542 0,
543 true,
544 );
545
546 assert_eq!(checkpoint.version, 2);
548 }
549
550 #[test]
551 fn test_checkpoint_v1_backward_compat() {
552 let json = r#"{
554 "symbol": "BTCUSDT",
555 "threshold_decimal_bps": 100,
556 "incomplete_bar": null,
557 "thresholds": null,
558 "last_timestamp_us": 1640995200000000,
559 "last_trade_id": 12345,
560 "price_hash": 0,
561 "anomaly_summary": {"gaps_detected": 0, "overlaps_detected": 0, "timestamp_anomalies": 0},
562 "prevent_same_timestamp_close": true,
563 "defer_open": false
564 }"#;
565
566 let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
568 assert_eq!(checkpoint.version, 1); assert_eq!(checkpoint.symbol, "BTCUSDT");
570 assert_eq!(checkpoint.threshold_decimal_bps, 100);
571
572 }
575
576 #[test]
577 fn test_checkpoint_v2_serialization() {
578 let checkpoint = Checkpoint::new(
579 "EURUSD".to_string(),
580 10,
581 None,
582 None,
583 1640995200000000,
584 None,
585 12345678,
586 true,
587 );
588
589 let json = serde_json::to_string(&checkpoint).unwrap();
591 assert!(json.contains("\"version\":2"));
592
593 let restored: Checkpoint = serde_json::from_str(&json).unwrap();
595 assert_eq!(restored.version, 2);
596 assert_eq!(restored.symbol, "EURUSD");
597 }
598
599 #[test]
604 fn test_price_window_empty() {
605 let pw = PriceWindow::new();
606 assert!(pw.is_empty());
607 assert_eq!(pw.len(), 0);
608 let hash1 = pw.compute_hash();
610 let hash2 = PriceWindow::new().compute_hash();
611 assert_eq!(hash1, hash2, "Empty window hash must be deterministic");
612 }
613
614 #[test]
615 fn test_price_window_partial_fill() {
616 let mut pw = PriceWindow::new();
617 pw.push(FixedPoint(100_000_000)); pw.push(FixedPoint(200_000_000)); pw.push(FixedPoint(300_000_000)); assert_eq!(pw.len(), 3);
622 assert!(!pw.is_empty());
623
624 let mut pw2 = PriceWindow::new();
626 pw2.push(FixedPoint(100_000_000));
627 pw2.push(FixedPoint(200_000_000));
628 pw2.push(FixedPoint(300_000_000));
629 assert_eq!(
630 pw.compute_hash(),
631 pw2.compute_hash(),
632 "Same prices = same hash"
633 );
634 }
635
636 #[test]
637 fn test_price_window_full_capacity() {
638 let mut pw = PriceWindow::new();
639 for i in 1..=PRICE_WINDOW_SIZE {
640 pw.push(FixedPoint(i as i64 * 100_000_000));
641 }
642 assert_eq!(pw.len(), PRICE_WINDOW_SIZE);
643
644 let hash1 = pw.compute_hash();
646 let hash2 = pw.compute_hash();
647 assert_eq!(hash1, hash2, "Hash must be idempotent");
648 }
649
650 #[test]
651 fn test_price_window_wrapping() {
652 let mut pw = PriceWindow::new();
653 for i in 1..=PRICE_WINDOW_SIZE {
655 pw.push(FixedPoint(i as i64 * 100_000_000));
656 }
657 let hash_before = pw.compute_hash();
658
659 pw.push(FixedPoint(999_000_000));
661 assert_eq!(
662 pw.len(),
663 PRICE_WINDOW_SIZE,
664 "Length stays at capacity after wrap"
665 );
666
667 let hash_after = pw.compute_hash();
668 assert_ne!(
669 hash_before, hash_after,
670 "Hash must change after circular overwrite"
671 );
672 }
673
674 #[test]
675 fn test_price_window_order_sensitivity() {
676 let mut pw1 = PriceWindow::new();
678 pw1.push(FixedPoint(100_000_000));
679 pw1.push(FixedPoint(200_000_000));
680 pw1.push(FixedPoint(300_000_000));
681
682 let mut pw2 = PriceWindow::new();
683 pw2.push(FixedPoint(300_000_000));
684 pw2.push(FixedPoint(200_000_000));
685 pw2.push(FixedPoint(100_000_000));
686
687 assert_ne!(
688 pw1.compute_hash(),
689 pw2.compute_hash(),
690 "Different order must produce different hash"
691 );
692 }
693
694 #[test]
695 fn test_price_window_push_beyond_capacity() {
696 let mut pw = PriceWindow::new();
697 for i in 1..=(PRICE_WINDOW_SIZE * 2) {
699 pw.push(FixedPoint(i as i64 * 100_000_000));
700 }
701 assert_eq!(pw.len(), PRICE_WINDOW_SIZE);
702
703 let mut pw_expected = PriceWindow::new();
706 for i in (PRICE_WINDOW_SIZE + 1)..=(PRICE_WINDOW_SIZE * 2) {
707 pw_expected.push(FixedPoint(i as i64 * 100_000_000));
708 }
709 assert_eq!(
710 pw.compute_hash(),
711 pw_expected.compute_hash(),
712 "After full wrap, hash must match the last N prices"
713 );
714 }
715}