use crate::fixed_point::FixedPoint;
use crate::types::OpenDeviationBar;
use foldhash::fast::FixedState;
use serde::{Deserialize, Serialize};
use std::hash::{BuildHasher, Hasher};
use thiserror::Error;
const PRICE_WINDOW_SIZE: usize = 8;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
#[serde(default = "default_checkpoint_version")]
pub version: u32,
pub symbol: String,
pub threshold_decimal_bps: u32,
pub incomplete_bar: Option<OpenDeviationBar>,
pub thresholds: Option<(FixedPoint, FixedPoint)>,
pub last_timestamp_us: i64,
pub last_trade_id: Option<i64>,
pub price_hash: u64,
pub anomaly_summary: AnomalySummary,
#[serde(default = "default_prevent_same_timestamp_close")]
pub prevent_same_timestamp_close: bool,
#[serde(default)]
pub defer_open: bool,
#[serde(default)]
pub last_completed_bar_tid: Option<i64>,
#[serde(default)]
pub spread_accumulator: Option<crate::spread_accumulator::SpreadAccumulator>,
#[serde(default)]
pub breach_mode: crate::trade::BreachMode,
}
fn default_checkpoint_version() -> u32 {
1
}
fn default_prevent_same_timestamp_close() -> bool {
true
}
impl Checkpoint {
pub fn new(
symbol: String,
threshold_decimal_bps: u32,
incomplete_bar: Option<OpenDeviationBar>,
thresholds: Option<(FixedPoint, FixedPoint)>,
last_timestamp_us: i64,
last_trade_id: Option<i64>,
price_hash: u64,
prevent_same_timestamp_close: bool,
) -> Self {
Self {
version: 2, symbol,
threshold_decimal_bps,
incomplete_bar,
thresholds,
last_timestamp_us,
last_trade_id,
price_hash,
anomaly_summary: AnomalySummary::default(),
prevent_same_timestamp_close,
defer_open: false,
last_completed_bar_tid: None,
spread_accumulator: None, breach_mode: crate::trade::BreachMode::default(), }
}
pub fn has_incomplete_bar(&self) -> bool {
self.incomplete_bar.is_some()
}
pub fn library_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AnomalySummary {
pub gaps_detected: u32,
pub overlaps_detected: u32,
pub timestamp_anomalies: u32,
}
impl AnomalySummary {
pub fn record_gap(&mut self) {
self.gaps_detected += 1;
}
pub fn record_overlap(&mut self) {
self.overlaps_detected += 1;
}
pub fn record_timestamp_anomaly(&mut self) {
self.timestamp_anomalies += 1;
}
pub fn has_anomalies(&self) -> bool {
self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
}
pub fn total(&self) -> u32 {
self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PositionVerification {
Exact,
Gap {
expected_id: i64,
actual_id: i64,
missing_count: i64,
},
TimestampOnly { gap_ms: i64 },
}
impl PositionVerification {
pub fn has_gap(&self) -> bool {
matches!(self, PositionVerification::Gap { .. })
}
}
#[derive(Error, Debug, Clone, PartialEq)]
pub enum CheckpointError {
#[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
SymbolMismatch {
checkpoint: String,
expected: String,
},
#[error("Threshold mismatch: checkpoint has {checkpoint} dbps, expected {expected} dbps")]
ThresholdMismatch { checkpoint: u32, expected: u32 },
#[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
PriceHashMismatch { checkpoint: u64, computed: u64 },
#[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
MissingThresholds,
#[error("Checkpoint serialization error: {message}")]
SerializationError { message: String },
#[error(
"Invalid threshold in checkpoint: {threshold} dbps. Valid range: {min_threshold}-{max_threshold} dbps"
)]
InvalidThreshold {
threshold: u32,
min_threshold: u32,
max_threshold: u32,
},
}
#[derive(Debug, Clone)]
pub struct PriceWindow {
prices: [i64; PRICE_WINDOW_SIZE],
index: usize,
count: usize,
}
impl Default for PriceWindow {
fn default() -> Self {
Self::new()
}
}
impl PriceWindow {
pub fn new() -> Self {
Self {
prices: [0; PRICE_WINDOW_SIZE],
index: 0,
count: 0,
}
}
pub fn push(&mut self, price: FixedPoint) {
self.prices[self.index] = price.0;
self.index = (self.index + 1) % PRICE_WINDOW_SIZE;
if self.count < PRICE_WINDOW_SIZE {
self.count += 1;
}
}
pub fn compute_hash(&self) -> u64 {
let mut hasher = FixedState::default().build_hasher();
if self.count < PRICE_WINDOW_SIZE {
for i in 0..self.count {
hasher.write_i64(self.prices[i]);
}
} else {
for i in 0..PRICE_WINDOW_SIZE {
let idx = (self.index + i) % PRICE_WINDOW_SIZE;
hasher.write_i64(self.prices[idx]);
}
}
hasher.finish()
}
pub fn len(&self) -> usize {
self.count
}
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_checkpoint_creation() {
let checkpoint = Checkpoint::new(
"BTCUSDT".to_string(),
250, None,
None,
1640995200000000, Some(12345),
0,
true, );
assert_eq!(checkpoint.symbol, "BTCUSDT");
assert_eq!(checkpoint.threshold_decimal_bps, 250);
assert!(!checkpoint.has_incomplete_bar());
assert_eq!(checkpoint.last_trade_id, Some(12345));
assert!(checkpoint.prevent_same_timestamp_close);
}
#[test]
fn test_checkpoint_serialization() {
let checkpoint = Checkpoint::new(
"EURUSD".to_string(),
10, None,
None,
1640995200000000,
None, 12345678,
true, );
let json = serde_json::to_string(&checkpoint).unwrap();
assert!(json.contains("EURUSD"));
assert!(json.contains("\"threshold_decimal_bps\":10"));
assert!(json.contains("\"prevent_same_timestamp_close\":true"));
let restored: Checkpoint = serde_json::from_str(&json).unwrap();
assert_eq!(restored.symbol, "EURUSD");
assert_eq!(restored.threshold_decimal_bps, 10);
assert_eq!(restored.price_hash, 12345678);
assert!(restored.prevent_same_timestamp_close);
}
#[test]
fn test_checkpoint_serialization_toggle_false() {
let checkpoint = Checkpoint::new(
"BTCUSDT".to_string(),
100, None,
None,
1640995200000000,
Some(999),
12345678,
false, );
let json = serde_json::to_string(&checkpoint).unwrap();
assert!(json.contains("\"prevent_same_timestamp_close\":false"));
let restored: Checkpoint = serde_json::from_str(&json).unwrap();
assert!(!restored.prevent_same_timestamp_close);
}
#[test]
fn test_checkpoint_deserialization_default() {
let json = r#"{
"symbol": "BTCUSDT",
"threshold_decimal_bps": 100,
"incomplete_bar": null,
"thresholds": null,
"last_timestamp_us": 1640995200000000,
"last_trade_id": 12345,
"price_hash": 0,
"anomaly_summary": {"gaps_detected": 0, "overlaps_detected": 0, "timestamp_anomalies": 0}
}"#;
let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
assert!(checkpoint.prevent_same_timestamp_close);
}
#[test]
fn test_anomaly_summary() {
let mut summary = AnomalySummary::default();
assert!(!summary.has_anomalies());
assert_eq!(summary.total(), 0);
summary.record_gap();
summary.record_gap();
summary.record_timestamp_anomaly();
assert!(summary.has_anomalies());
assert_eq!(summary.gaps_detected, 2);
assert_eq!(summary.timestamp_anomalies, 1);
assert_eq!(summary.total(), 3);
}
#[test]
fn test_price_window() {
let mut window = PriceWindow::new();
assert!(window.is_empty());
window.push(FixedPoint(5000000000000)); window.push(FixedPoint(5001000000000)); window.push(FixedPoint(5002000000000));
assert_eq!(window.len(), 3);
assert!(!window.is_empty());
let hash1 = window.compute_hash();
let mut window2 = PriceWindow::new();
window2.push(FixedPoint(5000000000000));
window2.push(FixedPoint(5001000000000));
window2.push(FixedPoint(5002000000000));
let hash2 = window2.compute_hash();
assert_eq!(hash1, hash2);
let mut window3 = PriceWindow::new();
window3.push(FixedPoint(5000000000000));
window3.push(FixedPoint(5001000000000));
window3.push(FixedPoint(5003000000000));
let hash3 = window3.compute_hash();
assert_ne!(hash1, hash3);
}
#[test]
fn test_price_window_circular() {
let mut window = PriceWindow::new();
for i in 0..12 {
window.push(FixedPoint(i * 100000000));
}
assert_eq!(window.len(), PRICE_WINDOW_SIZE);
let hash1 = window.compute_hash();
let hash2 = window.compute_hash();
assert_eq!(hash1, hash2);
}
#[test]
fn test_position_verification() {
let exact = PositionVerification::Exact;
assert!(!exact.has_gap());
let gap = PositionVerification::Gap {
expected_id: 100,
actual_id: 105,
missing_count: 5,
};
assert!(gap.has_gap());
let timestamp_only = PositionVerification::TimestampOnly { gap_ms: 1000 };
assert!(!timestamp_only.has_gap());
}
#[test]
fn test_library_version() {
let version = Checkpoint::library_version();
assert!(version.contains('.'));
println!("Library version: {}", version);
}
#[test]
fn test_checkpoint_versioning() {
let checkpoint = Checkpoint::new(
"BTCUSDT".to_string(),
250, None,
None,
1640995200000000,
Some(12345),
0,
true,
);
assert_eq!(checkpoint.version, 2);
}
#[test]
fn test_checkpoint_v1_backward_compat() {
let json = r#"{
"symbol": "BTCUSDT",
"threshold_decimal_bps": 100,
"incomplete_bar": null,
"thresholds": null,
"last_timestamp_us": 1640995200000000,
"last_trade_id": 12345,
"price_hash": 0,
"anomaly_summary": {"gaps_detected": 0, "overlaps_detected": 0, "timestamp_anomalies": 0},
"prevent_same_timestamp_close": true,
"defer_open": false
}"#;
let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
assert_eq!(checkpoint.version, 1); assert_eq!(checkpoint.symbol, "BTCUSDT");
assert_eq!(checkpoint.threshold_decimal_bps, 100);
}
#[test]
fn test_checkpoint_v2_serialization() {
let checkpoint = Checkpoint::new(
"EURUSD".to_string(),
10,
None,
None,
1640995200000000,
None,
12345678,
true,
);
let json = serde_json::to_string(&checkpoint).unwrap();
assert!(json.contains("\"version\":2"));
let restored: Checkpoint = serde_json::from_str(&json).unwrap();
assert_eq!(restored.version, 2);
assert_eq!(restored.symbol, "EURUSD");
}
#[test]
fn test_price_window_empty() {
let pw = PriceWindow::new();
assert!(pw.is_empty());
assert_eq!(pw.len(), 0);
let hash1 = pw.compute_hash();
let hash2 = PriceWindow::new().compute_hash();
assert_eq!(hash1, hash2, "Empty window hash must be deterministic");
}
#[test]
fn test_price_window_partial_fill() {
let mut pw = PriceWindow::new();
pw.push(FixedPoint(100_000_000)); pw.push(FixedPoint(200_000_000)); pw.push(FixedPoint(300_000_000));
assert_eq!(pw.len(), 3);
assert!(!pw.is_empty());
let mut pw2 = PriceWindow::new();
pw2.push(FixedPoint(100_000_000));
pw2.push(FixedPoint(200_000_000));
pw2.push(FixedPoint(300_000_000));
assert_eq!(
pw.compute_hash(),
pw2.compute_hash(),
"Same prices = same hash"
);
}
#[test]
fn test_price_window_full_capacity() {
let mut pw = PriceWindow::new();
for i in 1..=PRICE_WINDOW_SIZE {
pw.push(FixedPoint(i as i64 * 100_000_000));
}
assert_eq!(pw.len(), PRICE_WINDOW_SIZE);
let hash1 = pw.compute_hash();
let hash2 = pw.compute_hash();
assert_eq!(hash1, hash2, "Hash must be idempotent");
}
#[test]
fn test_price_window_wrapping() {
let mut pw = PriceWindow::new();
for i in 1..=PRICE_WINDOW_SIZE {
pw.push(FixedPoint(i as i64 * 100_000_000));
}
let hash_before = pw.compute_hash();
pw.push(FixedPoint(999_000_000));
assert_eq!(
pw.len(),
PRICE_WINDOW_SIZE,
"Length stays at capacity after wrap"
);
let hash_after = pw.compute_hash();
assert_ne!(
hash_before, hash_after,
"Hash must change after circular overwrite"
);
}
#[test]
fn test_price_window_order_sensitivity() {
let mut pw1 = PriceWindow::new();
pw1.push(FixedPoint(100_000_000));
pw1.push(FixedPoint(200_000_000));
pw1.push(FixedPoint(300_000_000));
let mut pw2 = PriceWindow::new();
pw2.push(FixedPoint(300_000_000));
pw2.push(FixedPoint(200_000_000));
pw2.push(FixedPoint(100_000_000));
assert_ne!(
pw1.compute_hash(),
pw2.compute_hash(),
"Different order must produce different hash"
);
}
#[test]
fn test_price_window_push_beyond_capacity() {
let mut pw = PriceWindow::new();
for i in 1..=(PRICE_WINDOW_SIZE * 2) {
pw.push(FixedPoint(i as i64 * 100_000_000));
}
assert_eq!(pw.len(), PRICE_WINDOW_SIZE);
let mut pw_expected = PriceWindow::new();
for i in (PRICE_WINDOW_SIZE + 1)..=(PRICE_WINDOW_SIZE * 2) {
pw_expected.push(FixedPoint(i as i64 * 100_000_000));
}
assert_eq!(
pw.compute_hash(),
pw_expected.compute_hash(),
"After full wrap, hash must match the last N prices"
);
}
}