opendeviationbar-core 13.75.0

Core open deviation bar construction algorithm with temporal integrity guarantees
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
//! Checkpoint system for cross-file open deviation bar continuation
//!
//! Enables seamless processing across file boundaries by serializing
//! incomplete bar state with IMMUTABLE thresholds.
//!
//! ## Primary Use Case
//!
//! ```text
//! File 1 ends with incomplete bar → save Checkpoint
//! File 2 starts → load Checkpoint → continue building bar
//! ```
//!
//! ## Key Invariants
//!
//! - Thresholds are computed from bar.open and are IMMUTABLE for bar's lifetime
//! - Incomplete bar state preserved across file boundaries
//! - Note: `bar[i+1].open` may differ from `bar[i].close` (next bar opens at first
//!   tick after previous bar closes, not at the close price itself)
//! - Supports sources with trade IDs (Binance) or timestamp-only sources

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;

/// Price window size for hash calculation (last N prices)
const PRICE_WINDOW_SIZE: usize = 8;

/// Checkpoint for cross-file open deviation bar continuation
///
/// Enables seamless processing across any file boundaries.
/// Captures minimal state needed to continue building an incomplete bar.
///
/// # Example
///
/// ```ignore
/// // Process first file
/// let bars_1 = processor.process_agg_trade_records(&file1_trades)?;
/// let checkpoint = processor.create_checkpoint("BTCUSDT");
///
/// // Serialize and save checkpoint
/// let json = serde_json::to_string(&checkpoint)?;
/// std::fs::write("checkpoint.json", json)?;
///
/// // ... later, load checkpoint and continue processing ...
/// let json = std::fs::read_to_string("checkpoint.json")?;
/// let checkpoint: Checkpoint = serde_json::from_str(&json)?;
/// let mut processor = OpenDeviationBarProcessor::from_checkpoint(checkpoint)?;
/// let bars_2 = processor.process_agg_trade_records(&file2_trades)?;
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
    // === VERSIONING (1 field) ===
    /// Schema version for checkpoint format (Issue #85: Phase 2)
    /// v1: Original format (default for old checkpoints)
    /// v2: Field-reordered OpenDeviationBar (no behavioral changes, safe for deserialization)
    #[serde(default = "default_checkpoint_version")]
    pub version: u32,

    // === IDENTIFICATION (2 fields) ===
    /// Symbol being processed (e.g., "BTCUSDT")
    pub symbol: String,

    /// Threshold in decimal basis points (v3.0.0+: 0.1bps units)
    /// Example: 250 = 25bps = 0.25%
    pub threshold_decimal_bps: u32,

    // === BAR STATE (2 fields) ===
    /// Incomplete bar at file boundary (None = last bar completed cleanly)
    /// REUSES existing OpenDeviationBar type - no separate BarState needed!
    pub incomplete_bar: Option<OpenDeviationBar>,

    /// Fixed thresholds for incomplete bar (computed from bar.open, IMMUTABLE)
    /// Stored as (upper_threshold, lower_threshold)
    pub thresholds: Option<(FixedPoint, FixedPoint)>,

    // === POSITION TRACKING (2 fields) ===
    /// Last processed timestamp in microseconds (universal, works for all sources)
    pub last_timestamp_us: i64,

    /// Last trade ID (Some when source has trade IDs, None for timestamp-only)
    /// Binance: agg_trade_id is strictly sequential, never resets
    pub last_trade_id: Option<i64>,

    // === INTEGRITY (1 field) ===
    /// Price window hash (ahash of last 8 prices for position verification)
    /// Used to verify we're resuming at the correct position in data stream
    pub price_hash: u64,

    // === MONITORING (1 field) ===
    /// Anomaly summary counts for debugging
    pub anomaly_summary: AnomalySummary,

    // === BEHAVIOR FLAGS (2 fields) ===
    /// Prevent bars from closing on same timestamp as they opened (Issue #36)
    ///
    /// When true (default): A bar cannot close until a trade arrives with a
    /// different timestamp than the bar's open_time. This prevents flash crash
    /// scenarios from creating thousands of bars at identical timestamps.
    ///
    /// When false: Legacy v8 behavior - bars can close immediately on breach.
    #[serde(default = "default_prevent_same_timestamp_close")]
    pub prevent_same_timestamp_close: bool,

    /// Deferred bar open flag (Issue #46)
    ///
    /// When true: The last trade before checkpoint triggered a threshold breach.
    /// On resume, the next trade should open a new bar instead of continuing.
    /// This matches the batch path's `defer_open` semantics.
    #[serde(default)]
    pub defer_open: bool,

    /// Last completed bar's trade ID for dedup floor initialization (v1.4)
    ///
    /// Updated ONLY on bar completion and orphan emission, NOT on every trade.
    /// Used by committed_floors to initialize from the last completed bar
    /// rather than the forming bar tail, preventing junction bar suppression.
    /// Defaults to None for old checkpoints (backward compatible).
    #[serde(default)]
    pub last_completed_bar_tid: Option<i64>,

    /// SpreadAccumulator state for cross-session continuity (Phase 53)
    ///
    /// Preserves the streaming spread statistics accumulator across checkpoint
    /// boundaries. None for crypto (Last breach mode) or old checkpoints.
    /// Uses serde(default) for backward compatibility with pre-Phase-53 checkpoints.
    #[serde(default)]
    pub spread_accumulator: Option<crate::spread_accumulator::SpreadAccumulator>,

    /// Breach mode for threshold detection (Phase 53)
    ///
    /// Determines which price is tested against thresholds and whether
    /// SpreadAccumulator is active. Defaults to Last for backward compatibility.
    #[serde(default)]
    pub breach_mode: crate::trade::BreachMode,
}

/// Default checkpoint version (v1 for backward compatibility)
fn default_checkpoint_version() -> u32 {
    1
}

/// Default value for prevent_same_timestamp_close (true = timestamp gating enabled)
fn default_prevent_same_timestamp_close() -> bool {
    true
}

impl Checkpoint {
    /// Create a new checkpoint with the given parameters
    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, // New checkpoints created with current version
            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, // Phase 53: None by default
            breach_mode: crate::trade::BreachMode::default(), // Phase 53: Default to Last
        }
    }

    /// Check if there's an incomplete bar that needs to continue
    pub fn has_incomplete_bar(&self) -> bool {
        self.incomplete_bar.is_some()
    }

    /// Get the library version that created this checkpoint
    pub fn library_version() -> &'static str {
        env!("CARGO_PKG_VERSION")
    }
}

/// Anomaly summary for quick inspection (counts only)
///
/// Tracks anomalies detected during processing for debugging purposes.
/// Does NOT affect processing - purely for monitoring.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AnomalySummary {
    /// Number of gaps detected (missing trade IDs or timestamp jumps)
    pub gaps_detected: u32,

    /// Number of overlaps detected (duplicate or out-of-order data)
    pub overlaps_detected: u32,

    /// Number of timestamp anomalies (negative intervals, etc.)
    pub timestamp_anomalies: u32,
}

impl AnomalySummary {
    /// Increment gap counter
    pub fn record_gap(&mut self) {
        self.gaps_detected += 1;
    }

    /// Increment overlap counter
    pub fn record_overlap(&mut self) {
        self.overlaps_detected += 1;
    }

    /// Increment timestamp anomaly counter
    pub fn record_timestamp_anomaly(&mut self) {
        self.timestamp_anomalies += 1;
    }

    /// Check if any anomalies were detected
    pub fn has_anomalies(&self) -> bool {
        self.gaps_detected > 0 || self.overlaps_detected > 0 || self.timestamp_anomalies > 0
    }

    /// Get total anomaly count
    pub fn total(&self) -> u32 {
        self.gaps_detected + self.overlaps_detected + self.timestamp_anomalies
    }
}

/// Position verification result when resuming from checkpoint
#[derive(Debug, Clone, PartialEq)]
pub enum PositionVerification {
    /// Trade ID matches expected (Binance: last_id + 1)
    Exact,

    /// Trade ID gap detected (Binance only)
    /// Contains expected_id, actual_id, and count of missing trades
    Gap {
        expected_id: i64,
        actual_id: i64,
        missing_count: i64,
    },

    /// No trade ID available, timestamp check only (timestamp-only sources)
    /// Contains gap in milliseconds since last checkpoint
    TimestampOnly { gap_ms: i64 },
}

impl PositionVerification {
    /// Check if position verification indicates a data gap
    pub fn has_gap(&self) -> bool {
        matches!(self, PositionVerification::Gap { .. })
    }
}

/// Checkpoint-related errors
#[derive(Error, Debug, Clone, PartialEq)]
pub enum CheckpointError {
    /// Symbol mismatch between checkpoint and processor
    #[error("Symbol mismatch: checkpoint has '{checkpoint}', expected '{expected}'")]
    SymbolMismatch {
        checkpoint: String,
        expected: String,
    },

    /// Threshold mismatch between checkpoint and processor
    #[error("Threshold mismatch: checkpoint has {checkpoint} dbps, expected {expected} dbps")]
    ThresholdMismatch { checkpoint: u32, expected: u32 },

    /// Price hash mismatch indicates wrong position in data stream
    #[error("Price hash mismatch: checkpoint has {checkpoint}, computed {computed}")]
    PriceHashMismatch { checkpoint: u64, computed: u64 },

    /// Checkpoint has incomplete bar but no thresholds
    #[error("Checkpoint has incomplete bar but missing thresholds - corrupted checkpoint")]
    MissingThresholds,

    /// Checkpoint serialization/deserialization error
    #[error("Checkpoint serialization error: {message}")]
    SerializationError { message: String },

    /// Invalid threshold in checkpoint (Issue #62: crypto minimum threshold enforcement)
    #[error(
        "Invalid threshold in checkpoint: {threshold} dbps. Valid range: {min_threshold}-{max_threshold} dbps"
    )]
    InvalidThreshold {
        threshold: u32,
        min_threshold: u32,
        max_threshold: u32,
    },
}

/// Price window for computing position verification hash
///
/// Maintains a circular buffer of the last N prices for hash computation.
#[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 {
    /// Create a new empty price window
    pub fn new() -> Self {
        Self {
            prices: [0; PRICE_WINDOW_SIZE],
            index: 0,
            count: 0,
        }
    }

    /// Add a price to the window (circular buffer)
    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;
        }
    }

    /// Compute hash of the price window using foldhash
    ///
    /// Returns a 64-bit hash that can be used to verify position in data stream.
    pub fn compute_hash(&self) -> u64 {
        let mut hasher = FixedState::default().build_hasher();

        // Hash prices in order they were added (oldest to newest)
        if self.count < PRICE_WINDOW_SIZE {
            // Buffer not full yet - hash from start
            for i in 0..self.count {
                hasher.write_i64(self.prices[i]);
            }
        } else {
            // Buffer full - hash from current index (oldest) around
            for i in 0..PRICE_WINDOW_SIZE {
                let idx = (self.index + i) % PRICE_WINDOW_SIZE;
                hasher.write_i64(self.prices[idx]);
            }
        }

        hasher.finish()
    }

    /// Get the number of prices in the window
    pub fn len(&self) -> usize {
        self.count
    }

    /// Check if the window is empty
    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, // 25bps
            None,
            None,
            1640995200000000, // timestamp in microseconds
            Some(12345),
            0,
            true, // prevent_same_timestamp_close
        );

        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(
            "BTCUSDT".to_string(),
            10, // 1bps
            None,
            None,
            1640995200000000,
            None, // Simulates a data source with no trade IDs
            12345678,
            true, // prevent_same_timestamp_close
        );

        // Serialize to JSON
        let json = serde_json::to_string(&checkpoint).unwrap();
        assert!(json.contains("BTCUSDT"));
        assert!(json.contains("\"threshold_decimal_bps\":10"));
        assert!(json.contains("\"prevent_same_timestamp_close\":true"));

        // Deserialize back
        let restored: Checkpoint = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.symbol, "BTCUSDT");
        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, // 10bps
            None,
            None,
            1640995200000000,
            Some(999),
            12345678,
            false, // Legacy behavior
        );

        // Serialize to JSON
        let json = serde_json::to_string(&checkpoint).unwrap();
        assert!(json.contains("\"prevent_same_timestamp_close\":false"));

        // Deserialize back
        let restored: Checkpoint = serde_json::from_str(&json).unwrap();
        assert!(!restored.prevent_same_timestamp_close);
    }

    #[test]
    fn test_checkpoint_deserialization_default() {
        // Test that old checkpoints without the field default to true
        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();
        // Missing field should default to true (new behavior)
        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());

        // Add some prices
        window.push(FixedPoint(5000000000000)); // 50000.0
        window.push(FixedPoint(5001000000000)); // 50010.0
        window.push(FixedPoint(5002000000000)); // 50020.0

        assert_eq!(window.len(), 3);
        assert!(!window.is_empty());

        let hash1 = window.compute_hash();

        // Same prices should produce same 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);

        // Different prices should produce different hash
        let mut window3 = PriceWindow::new();
        window3.push(FixedPoint(5000000000000));
        window3.push(FixedPoint(5001000000000));
        window3.push(FixedPoint(5003000000000)); // Different!

        let hash3 = window3.compute_hash();
        assert_ne!(hash1, hash3);
    }

    #[test]
    fn test_price_window_circular() {
        let mut window = PriceWindow::new();

        // Fill the window beyond capacity
        for i in 0..12 {
            window.push(FixedPoint(i * 100000000));
        }

        // Should only contain last 8 prices
        assert_eq!(window.len(), PRICE_WINDOW_SIZE);

        // Hash should be consistent
        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();
        // Should be a valid semver string
        assert!(version.contains('.'));
        println!("Library version: {}", version);
    }

    #[test]
    fn test_checkpoint_versioning() {
        let checkpoint = Checkpoint::new(
            "BTCUSDT".to_string(),
            250, // 25bps
            None,
            None,
            1640995200000000,
            Some(12345),
            0,
            true,
        );

        // New checkpoints should have v2
        assert_eq!(checkpoint.version, 2);
    }

    #[test]
    fn test_checkpoint_v1_backward_compat() {
        // Issue #85: Simulate old v1 checkpoint (without version field)
        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
        }"#;

        // Old v1 checkpoints should deserialize with version defaulting to 1
        let checkpoint: Checkpoint = serde_json::from_str(json).unwrap();
        assert_eq!(checkpoint.version, 1); // Deserialized from default
        assert_eq!(checkpoint.symbol, "BTCUSDT");
        assert_eq!(checkpoint.threshold_decimal_bps, 100);

        // Migration is applied by OpenDeviationBarProcessor::from_checkpoint()
        // which is tested in processor.rs::test_checkpoint_v1_to_v2_migration
    }

    #[test]
    fn test_checkpoint_v2_serialization() {
        let checkpoint = Checkpoint::new(
            "BTCUSDT".to_string(),
            10,
            None,
            None,
            1640995200000000,
            None,
            12345678,
            true,
        );

        // Serialize v2 checkpoint
        let json = serde_json::to_string(&checkpoint).unwrap();
        assert!(json.contains("\"version\":2"));

        // Deserialize back
        let restored: Checkpoint = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.version, 2);
        assert_eq!(restored.symbol, "BTCUSDT");
    }

    // =========================================================================
    // Issue #96: PriceWindow circular buffer edge case tests
    // =========================================================================

    #[test]
    fn test_price_window_empty() {
        let pw = PriceWindow::new();
        assert!(pw.is_empty());
        assert_eq!(pw.len(), 0);
        // Hash of empty window should be deterministic
        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)); // 1.0
        pw.push(FixedPoint(200_000_000)); // 2.0
        pw.push(FixedPoint(300_000_000)); // 3.0

        assert_eq!(pw.len(), 3);
        assert!(!pw.is_empty());

        // Hash should be deterministic for same sequence
        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);

        // Verify hash consistency
        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();
        // Fill to capacity
        for i in 1..=PRICE_WINDOW_SIZE {
            pw.push(FixedPoint(i as i64 * 100_000_000));
        }
        let hash_before = pw.compute_hash();

        // Push one more (wraps, oldest evicted)
        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() {
        // Same prices, different order → different hash
        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();
        // Push 2x capacity to exercise full circular behavior
        for i in 1..=(PRICE_WINDOW_SIZE * 2) {
            pw.push(FixedPoint(i as i64 * 100_000_000));
        }
        assert_eq!(pw.len(), PRICE_WINDOW_SIZE);

        // Should only contain the last PRICE_WINDOW_SIZE prices
        // Hash should match a fresh window with those same prices
        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"
        );
    }
}