Skip to main content

opendeviationbar_core/
checkpoint.rs

1//! Checkpoint system for cross-file open deviation bar continuation
2//!
3//! Enables seamless processing across file boundaries by serializing
4//! incomplete bar state with IMMUTABLE thresholds.
5//!
6//! ## Primary Use Case
7//!
8//! ```text
9//! File 1 ends with incomplete bar → save Checkpoint
10//! File 2 starts → load Checkpoint → continue building bar
11//! ```
12//!
13//! ## Key Invariants
14//!
15//! - Thresholds are computed from bar.open and are IMMUTABLE for bar's lifetime
16//! - Incomplete bar state preserved across file boundaries
17//! - Note: `bar[i+1].open` may differ from `bar[i].close` (next bar opens at first
18//!   tick after previous bar closes, not at the close price itself)
19//! - Works with both Binance (has agg_trade_id) and Exness (timestamp-only)
20
21use 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
28/// Price window size for hash calculation (last N prices)
29const PRICE_WINDOW_SIZE: usize = 8;
30
31/// Checkpoint for cross-file open deviation bar continuation
32///
33/// Enables seamless processing across any file boundaries (Binance daily, Exness monthly).
34/// Captures minimal state needed to continue building an incomplete bar.
35///
36/// # Example
37///
38/// ```ignore
39/// // Process first file
40/// let bars_1 = processor.process_agg_trade_records(&file1_trades)?;
41/// let checkpoint = processor.create_checkpoint("BTCUSDT");
42///
43/// // Serialize and save checkpoint
44/// let json = serde_json::to_string(&checkpoint)?;
45/// std::fs::write("checkpoint.json", json)?;
46///
47/// // ... later, load checkpoint and continue processing ...
48/// let json = std::fs::read_to_string("checkpoint.json")?;
49/// let checkpoint: Checkpoint = serde_json::from_str(&json)?;
50/// let mut processor = OpenDeviationBarProcessor::from_checkpoint(checkpoint)?;
51/// let bars_2 = processor.process_agg_trade_records(&file2_trades)?;
52/// ```
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Checkpoint {
55    // === VERSIONING (1 field) ===
56    /// Schema version for checkpoint format (Issue #85: Phase 2)
57    /// v1: Original format (default for old checkpoints)
58    /// v2: Field-reordered OpenDeviationBar (no behavioral changes, safe for deserialization)
59    #[serde(default = "default_checkpoint_version")]
60    pub version: u32,
61
62    // === IDENTIFICATION (2 fields) ===
63    /// Symbol being processed (e.g., "BTCUSDT", "EURUSD")
64    pub symbol: String,
65
66    /// Threshold in decimal basis points (v3.0.0+: 0.1bps units)
67    /// Example: 250 = 25bps = 0.25%
68    pub threshold_decimal_bps: u32,
69
70    // === BAR STATE (2 fields) ===
71    /// Incomplete bar at file boundary (None = last bar completed cleanly)
72    /// REUSES existing OpenDeviationBar type - no separate BarState needed!
73    pub incomplete_bar: Option<OpenDeviationBar>,
74
75    /// Fixed thresholds for incomplete bar (computed from bar.open, IMMUTABLE)
76    /// Stored as (upper_threshold, lower_threshold)
77    pub thresholds: Option<(FixedPoint, FixedPoint)>,
78
79    // === POSITION TRACKING (2 fields) ===
80    /// Last processed timestamp in microseconds (universal, works for all sources)
81    pub last_timestamp_us: i64,
82
83    /// Last trade ID (Some for Binance, None for Exness)
84    /// Binance: agg_trade_id is strictly sequential, never resets
85    pub last_trade_id: Option<i64>,
86
87    // === INTEGRITY (1 field) ===
88    /// Price window hash (ahash of last 8 prices for position verification)
89    /// Used to verify we're resuming at the correct position in data stream
90    pub price_hash: u64,
91
92    // === MONITORING (1 field) ===
93    /// Anomaly summary counts for debugging
94    pub anomaly_summary: AnomalySummary,
95
96    // === BEHAVIOR FLAGS (2 fields) ===
97    /// Prevent bars from closing on same timestamp as they opened (Issue #36)
98    ///
99    /// When true (default): A bar cannot close until a trade arrives with a
100    /// different timestamp than the bar's open_time. This prevents flash crash
101    /// scenarios from creating thousands of bars at identical timestamps.
102    ///
103    /// When false: Legacy v8 behavior - bars can close immediately on breach.
104    #[serde(default = "default_prevent_same_timestamp_close")]
105    pub prevent_same_timestamp_close: bool,
106
107    /// Deferred bar open flag (Issue #46)
108    ///
109    /// When true: The last trade before checkpoint triggered a threshold breach.
110    /// On resume, the next trade should open a new bar instead of continuing.
111    /// This matches the batch path's `defer_open` semantics.
112    #[serde(default)]
113    pub defer_open: bool,
114
115    /// Last completed bar's trade ID for dedup floor initialization (v1.4)
116    ///
117    /// Updated ONLY on bar completion and orphan emission, NOT on every trade.
118    /// Used by committed_floors to initialize from the last completed bar
119    /// rather than the forming bar tail, preventing junction bar suppression.
120    /// Defaults to None for old checkpoints (backward compatible).
121    #[serde(default)]
122    pub last_completed_bar_tid: Option<i64>,
123}
124
125/// Default checkpoint version (v1 for backward compatibility)
126fn default_checkpoint_version() -> u32 {
127    1
128}
129
130/// Default value for prevent_same_timestamp_close (true = timestamp gating enabled)
131fn default_prevent_same_timestamp_close() -> bool {
132    true
133}
134
135impl Checkpoint {
136    /// Create a new checkpoint with the given parameters
137    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, // New checkpoints created with current version
149            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    /// Check if there's an incomplete bar that needs to continue
164    pub fn has_incomplete_bar(&self) -> bool {
165        self.incomplete_bar.is_some()
166    }
167
168    /// Get the library version that created this checkpoint
169    pub fn library_version() -> &'static str {
170        env!("CARGO_PKG_VERSION")
171    }
172}
173
174/// Anomaly summary for quick inspection (counts only)
175///
176/// Tracks anomalies detected during processing for debugging purposes.
177/// Does NOT affect processing - purely for monitoring.
178#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
179pub struct AnomalySummary {
180    /// Number of gaps detected (missing trade IDs or timestamp jumps)
181    pub gaps_detected: u32,
182
183    /// Number of overlaps detected (duplicate or out-of-order data)
184    pub overlaps_detected: u32,
185
186    /// Number of timestamp anomalies (negative intervals, etc.)
187    pub timestamp_anomalies: u32,
188}
189
190impl AnomalySummary {
191    /// Increment gap counter
192    pub fn record_gap(&mut self) {
193        self.gaps_detected += 1;
194    }
195
196    /// Increment overlap counter
197    pub fn record_overlap(&mut self) {
198        self.overlaps_detected += 1;
199    }
200
201    /// Increment timestamp anomaly counter
202    pub fn record_timestamp_anomaly(&mut self) {
203        self.timestamp_anomalies += 1;
204    }
205
206    /// Check if any anomalies were detected
207    pub fn has_anomalies(&self) -> bool {
208        self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
209    }
210
211    /// Get total anomaly count
212    pub fn total(&self) -> u32 {
213        self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
214    }
215}
216
217/// Position verification result when resuming from checkpoint
218#[derive(Debug, Clone, PartialEq)]
219pub enum PositionVerification {
220    /// Trade ID matches expected (Binance: last_id + 1)
221    Exact,
222
223    /// Trade ID gap detected (Binance only)
224    /// Contains expected_id, actual_id, and count of missing trades
225    Gap {
226        expected_id: i64,
227        actual_id: i64,
228        missing_count: i64,
229    },
230
231    /// No trade ID available, timestamp check only (Exness)
232    /// Contains gap in milliseconds since last checkpoint
233    TimestampOnly { gap_ms: i64 },
234}
235
236impl PositionVerification {
237    /// Check if position verification indicates a data gap
238    pub fn has_gap(&self) -> bool {
239        matches!(self, PositionVerification::Gap { .. })
240    }
241}
242
243/// Checkpoint-related errors
244#[derive(Error, Debug, Clone, PartialEq)]
245pub enum CheckpointError {
246    /// Symbol mismatch between checkpoint and processor
247    #[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
248    SymbolMismatch {
249        checkpoint: String,
250        expected: String,
251    },
252
253    /// Threshold mismatch between checkpoint and processor
254    #[error("Threshold mismatch: checkpoint has {checkpoint} dbps, expected {expected} dbps")]
255    ThresholdMismatch { checkpoint: u32, expected: u32 },
256
257    /// Price hash mismatch indicates wrong position in data stream
258    #[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
259    PriceHashMismatch { checkpoint: u64, computed: u64 },
260
261    /// Checkpoint has incomplete bar but no thresholds
262    #[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
263    MissingThresholds,
264
265    /// Checkpoint serialization/deserialization error
266    #[error("Checkpoint serialization error: {message}")]
267    SerializationError { message: String },
268
269    /// Invalid threshold in checkpoint (Issue #62: crypto minimum threshold enforcement)
270    #[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/// Price window for computing position verification hash
281///
282/// Maintains a circular buffer of the last N prices for hash computation.
283#[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    /// Create a new empty price window
298    pub fn new() -> Self {
299        Self {
300            prices: [0; PRICE_WINDOW_SIZE],
301            index: 0,
302            count: 0,
303        }
304    }
305
306    /// Add a price to the window (circular buffer)
307    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    /// Compute hash of the price window using foldhash
316    ///
317    /// Returns a 64-bit hash that can be used to verify position in data stream.
318    pub fn compute_hash(&self) -> u64 {
319        let mut hasher = FixedState::default().build_hasher();
320
321        // Hash prices in order they were added (oldest to newest)
322        if self.count < PRICE_WINDOW_SIZE {
323            // Buffer not full yet - hash from start
324            for i in 0..self.count {
325                hasher.write_i64(self.prices[i]);
326            }
327        } else {
328            // Buffer full - hash from current index (oldest) around
329            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    /// Get the number of prices in the window
339    pub fn len(&self) -> usize {
340        self.count
341    }
342
343    /// Check if the window is empty
344    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, // 25bps
358            None,
359            None,
360            1640995200000000, // timestamp in microseconds
361            Some(12345),
362            0,
363            true, // prevent_same_timestamp_close
364        );
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, // 1bps
378            None,
379            None,
380            1640995200000000,
381            None, // Exness has no trade IDs
382            12345678,
383            true, // prevent_same_timestamp_close
384        );
385
386        // Serialize to JSON
387        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        // Deserialize back
393        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, // 10bps
405            None,
406            None,
407            1640995200000000,
408            Some(999),
409            12345678,
410            false, // Legacy behavior
411        );
412
413        // Serialize to JSON
414        let json = serde_json::to_string(&checkpoint).unwrap();
415        assert!(json.contains("\"prevent_same_timestamp_close\":false"));
416
417        // Deserialize back
418        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        // Test that old checkpoints without the field default to true
425        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        // Missing field should default to true (new behavior)
438        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        // Add some prices
463        window.push(FixedPoint(5000000000000)); // 50000.0
464        window.push(FixedPoint(5001000000000)); // 50010.0
465        window.push(FixedPoint(5002000000000)); // 50020.0
466
467        assert_eq!(window.len(), 3);
468        assert!(!window.is_empty());
469
470        let hash1 = window.compute_hash();
471
472        // Same prices should produce same hash
473        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        // Different prices should produce different hash
482        let mut window3 = PriceWindow::new();
483        window3.push(FixedPoint(5000000000000));
484        window3.push(FixedPoint(5001000000000));
485        window3.push(FixedPoint(5003000000000)); // Different!
486
487        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        // Fill the window beyond capacity
496        for i in 0..12 {
497            window.push(FixedPoint(i * 100000000));
498        }
499
500        // Should only contain last 8 prices
501        assert_eq!(window.len(), PRICE_WINDOW_SIZE);
502
503        // Hash should be consistent
504        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        // Should be a valid semver string
529        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, // 25bps
538            None,
539            None,
540            1640995200000000,
541            Some(12345),
542            0,
543            true,
544        );
545
546        // New checkpoints should have v2
547        assert_eq!(checkpoint.version, 2);
548    }
549
550    #[test]
551    fn test_checkpoint_v1_backward_compat() {
552        // Issue #85: Simulate old v1 checkpoint (without version field)
553        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        // Old v1 checkpoints should deserialize with version defaulting to 1
567        let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
568        assert_eq!(checkpoint.version, 1); // Deserialized from default
569        assert_eq!(checkpoint.symbol, "BTCUSDT");
570        assert_eq!(checkpoint.threshold_decimal_bps, 100);
571
572        // Migration is applied by OpenDeviationBarProcessor::from_checkpoint()
573        // which is tested in processor.rs::test_checkpoint_v1_to_v2_migration
574    }
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        // Serialize v2 checkpoint
590        let json = serde_json::to_string(&checkpoint).unwrap();
591        assert!(json.contains("\"version\":2"));
592
593        // Deserialize back
594        let restored: Checkpoint = serde_json::from_str(&json).unwrap();
595        assert_eq!(restored.version, 2);
596        assert_eq!(restored.symbol, "EURUSD");
597    }
598
599    // =========================================================================
600    // Issue #96: PriceWindow circular buffer edge case tests
601    // =========================================================================
602
603    #[test]
604    fn test_price_window_empty() {
605        let pw = PriceWindow::new();
606        assert!(pw.is_empty());
607        assert_eq!(pw.len(), 0);
608        // Hash of empty window should be deterministic
609        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)); // 1.0
618        pw.push(FixedPoint(200_000_000)); // 2.0
619        pw.push(FixedPoint(300_000_000)); // 3.0
620
621        assert_eq!(pw.len(), 3);
622        assert!(!pw.is_empty());
623
624        // Hash should be deterministic for same sequence
625        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        // Verify hash consistency
645        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        // Fill to capacity
654        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        // Push one more (wraps, oldest evicted)
660        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        // Same prices, different order → different hash
677        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        // Push 2x capacity to exercise full circular behavior
698        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        // Should only contain the last PRICE_WINDOW_SIZE prices
704        // Hash should match a fresh window with those same prices
705        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}