Skip to main content

rangebar_core/
interbar.rs

1// FILE-SIZE-OK: Tests stay inline (access pub(crate) math functions via glob import). Phase 2b extracted types, Phase 2e extracted math.
2//! Inter-bar microstructure features computed from lookback trade windows
3//!
4//! GitHub Issue: https://github.com/terrylica/rangebar-py/issues/59
5//!
6//! This module provides features computed from trades that occurred BEFORE each bar opened,
7//! enabling enrichment of larger range bars (e.g., 1000 dbps) with finer-grained microstructure
8//! signals without lookahead bias.
9//!
10//! ## Temporal Integrity
11//!
12//! All features are computed from trades with timestamps strictly BEFORE the current bar's
13//! `open_time`. This ensures no lookahead bias in ML applications.
14//!
15//! ## Feature Tiers
16//!
17//! - **Tier 1**: Core features (7) - low complexity, high value
18//! - **Tier 2**: Statistical features (5) - medium complexity
19//! - **Tier 3**: Advanced features (4) - higher complexity, from trading-fitness patterns
20//!
21//! ## Academic References
22//!
23//! | Feature | Reference |
24//! |---------|-----------|
25//! | OFI | Chordia et al. (2002) - Order imbalance |
26//! | Kyle's Lambda | Kyle (1985) - Continuous auctions and insider trading |
27//! | Burstiness | Goh & Barabási (2008) - Burstiness and memory in complex systems |
28//! | Kaufman ER | Kaufman (1995) - Smarter Trading |
29//! | Garman-Klass | Garman & Klass (1980) - On the Estimation of Security Price Volatilities |
30//! | Hurst (DFA) | Peng et al. (1994) - Mosaic organization of DNA nucleotides |
31//! | Permutation Entropy | Bandt & Pompe (2002) - Permutation Entropy: A Natural Complexity Measure |
32
33use crate::fixed_point::FixedPoint;
34use crate::interbar_math::*;
35use crate::types::AggTrade;
36use std::collections::VecDeque;
37
38// Re-export types from interbar_types.rs (Phase 2b extraction)
39pub use crate::interbar_types::{InterBarConfig, InterBarFeatures, LookbackMode, TradeSnapshot};
40
41/// Trade history ring buffer for inter-bar feature computation
42#[derive(Debug, Clone)]
43pub struct TradeHistory {
44    /// Ring buffer of recent trades
45    trades: VecDeque<TradeSnapshot>,
46    /// Configuration for lookback
47    config: InterBarConfig,
48    /// Timestamp threshold: trades with timestamp < this are protected from pruning.
49    /// Set to the oldest timestamp we might need for lookback computation.
50    /// Updated each time a new bar opens.
51    protected_until: Option<i64>,
52    /// Total number of trades pushed (monotonic counter for BarRelative indexing)
53    total_pushed: usize,
54    /// Indices into total_pushed at which each bar closed (Issue #81).
55    /// `bar_close_indices[i]` = `total_pushed` value when bar i closed.
56    /// Used by `BarRelative` mode to determine how many trades to keep.
57    bar_close_indices: VecDeque<usize>,
58}
59
60impl TradeHistory {
61    /// Create new trade history with given configuration
62    pub fn new(config: InterBarConfig) -> Self {
63        let capacity = match &config.lookback_mode {
64            LookbackMode::FixedCount(n) => *n * 2, // 2x capacity to hold pre-bar + in-bar trades
65            LookbackMode::FixedWindow(_) | LookbackMode::BarRelative(_) => 2000, // Dynamic initial capacity
66        };
67        Self {
68            trades: VecDeque::with_capacity(capacity),
69            config,
70            protected_until: None,
71            total_pushed: 0,
72            bar_close_indices: VecDeque::new(),
73        }
74    }
75
76    /// Push a new trade to the history buffer
77    ///
78    /// Automatically prunes old entries based on lookback mode, but preserves
79    /// trades needed for lookback computation (timestamp < protected_until).
80    pub fn push(&mut self, trade: &AggTrade) {
81        let snapshot = TradeSnapshot::from(trade);
82        self.trades.push_back(snapshot);
83        self.total_pushed += 1;
84        self.prune();
85    }
86
87    /// Notify that a new bar has opened at the given timestamp
88    ///
89    /// This sets the protection threshold to ensure trades from before the bar
90    /// opened are preserved for lookback computation. The protection extends
91    /// until the next bar opens and calls this method again.
92    pub fn on_bar_open(&mut self, bar_open_time: i64) {
93        // Protect all trades with timestamp < bar_open_time
94        // These are the trades that can be used for lookback computation
95        self.protected_until = Some(bar_open_time);
96    }
97
98    /// Notify that the current bar has closed
99    ///
100    /// For `BarRelative` mode, records the current trade count as a bar boundary.
101    /// For other modes, this is a no-op. Protection is always kept until the
102    /// next bar opens.
103    pub fn on_bar_close(&mut self) {
104        // Record bar boundary for BarRelative pruning (Issue #81)
105        if let LookbackMode::BarRelative(n_bars) = &self.config.lookback_mode {
106            self.bar_close_indices.push_back(self.total_pushed);
107            // Keep only last n_bars+1 boundaries (n_bars for lookback + 1 for current)
108            while self.bar_close_indices.len() > *n_bars + 1 {
109                self.bar_close_indices.pop_front();
110            }
111        }
112        // Keep protection until next bar opens (all modes)
113    }
114
115    /// Prune old trades based on lookback configuration
116    ///
117    /// Pruning logic:
118    /// - For `FixedCount(n)`: Keep up to 2*n trades total, but never prune trades
119    ///   with timestamp < `protected_until` (needed for lookback)
120    /// - For `FixedWindow`: Standard time-based pruning, but respect `protected_until`
121    /// - For `BarRelative(n)`: Keep trades from last n completed bars (Issue #81)
122    fn prune(&mut self) {
123        match &self.config.lookback_mode {
124            LookbackMode::FixedCount(n) => {
125                // Keep at most 2*n trades (n for lookback + n for next bar's lookback)
126                let max_trades = *n * 2;
127                while self.trades.len() > max_trades {
128                    // Check if front trade is protected
129                    if let Some(front) = self.trades.front() {
130                        if let Some(protected) = self.protected_until {
131                            if front.timestamp < protected {
132                                // Don't prune protected trades
133                                break;
134                            }
135                        }
136                    }
137                    self.trades.pop_front();
138                }
139            }
140            LookbackMode::FixedWindow(window_us) => {
141                // Find the oldest trade we need
142                let newest_timestamp = self.trades.back().map(|t| t.timestamp).unwrap_or(0);
143                let cutoff = newest_timestamp - window_us;
144
145                while let Some(front) = self.trades.front() {
146                    // Respect protection
147                    if let Some(protected) = self.protected_until {
148                        if front.timestamp < protected {
149                            break;
150                        }
151                    }
152                    // Prune if outside time window
153                    if front.timestamp < cutoff {
154                        self.trades.pop_front();
155                    } else {
156                        break;
157                    }
158                }
159            }
160            LookbackMode::BarRelative(n_bars) => {
161                // Issue #81: Keep trades from last n completed bars.
162                //
163                // bar_close_indices stores total_pushed at each bar close:
164                //   B0 = end of bar 0 / start of bar 1's trades
165                //   B1 = end of bar 1 / start of bar 2's trades
166                //   etc.
167                //
168                // To include N bars of lookback, we need boundary B_{k-1}
169                // where k is the oldest bar we want. on_bar_close() keeps
170                // at most n_bars+1 entries, so after steady state, front()
171                // is exactly B_{k-1}.
172                //
173                // Bootstrap: when fewer than n_bars bars have closed, we
174                // want ALL available bars, so keep everything.
175                if self.bar_close_indices.len() <= *n_bars {
176                    // Bootstrap: fewer completed bars than lookback depth.
177                    // Keep all trades — we want every available bar.
178                    return;
179                }
180
181                // Steady state: front() is the boundary BEFORE the oldest
182                // bar we want. Trades from front() onward belong to the
183                // N-bar lookback window plus the current in-progress bar.
184                let oldest_boundary = self.bar_close_indices.front().copied().unwrap_or(0);
185                let keep_count = self.total_pushed - oldest_boundary;
186
187                // Prune unconditionally — bar boundaries are the source of truth
188                while self.trades.len() > keep_count {
189                    self.trades.pop_front();
190                }
191            }
192        }
193    }
194
195    /// Get trades for lookback computation (excludes trades at or after bar_open_time)
196    ///
197    /// This is CRITICAL for temporal integrity - we only use trades that
198    /// occurred BEFORE the current bar opened.
199    pub fn get_lookback_trades(&self, bar_open_time: i64) -> Vec<&TradeSnapshot> {
200        self.trades
201            .iter()
202            .filter(|t| t.timestamp < bar_open_time)
203            .collect()
204    }
205
206    /// Compute inter-bar features from lookback window
207    ///
208    /// # Arguments
209    ///
210    /// * `bar_open_time` - The open timestamp of the current bar (microseconds)
211    ///
212    /// # Returns
213    ///
214    /// `InterBarFeatures` with computed values, or `None` for features that
215    /// cannot be computed due to insufficient data.
216    pub fn compute_features(&self, bar_open_time: i64) -> InterBarFeatures {
217        let lookback: Vec<&TradeSnapshot> = self.get_lookback_trades(bar_open_time);
218
219        if lookback.is_empty() {
220            return InterBarFeatures::default();
221        }
222
223        let mut features = InterBarFeatures::default();
224
225        // === Tier 1: Core Features ===
226        self.compute_tier1_features(&lookback, &mut features);
227
228        // === Tier 2: Statistical Features ===
229        if self.config.compute_tier2 {
230            self.compute_tier2_features(&lookback, &mut features);
231        }
232
233        // === Tier 3: Advanced Features ===
234        if self.config.compute_tier3 {
235            self.compute_tier3_features(&lookback, &mut features);
236        }
237
238        features
239    }
240
241    /// Compute Tier 1 features (7 features, min 1 trade)
242    fn compute_tier1_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
243        let n = lookback.len();
244        if n == 0 {
245            return;
246        }
247
248        // Trade count
249        features.lookback_trade_count = Some(n as u32);
250
251        // Accumulate buy/sell volumes and counts
252        let (buy_vol, sell_vol, buy_count, sell_count, total_turnover) =
253            lookback
254                .iter()
255                .fold((0.0, 0.0, 0u32, 0u32, 0i128), |acc, t| {
256                    if t.is_buyer_maker {
257                        // Sell pressure
258                        (
259                            acc.0,
260                            acc.1 + t.volume.to_f64(),
261                            acc.2,
262                            acc.3 + 1,
263                            acc.4 + t.turnover,
264                        )
265                    } else {
266                        // Buy pressure
267                        (
268                            acc.0 + t.volume.to_f64(),
269                            acc.1,
270                            acc.2 + 1,
271                            acc.3,
272                            acc.4 + t.turnover,
273                        )
274                    }
275                });
276
277        let total_vol = buy_vol + sell_vol;
278
279        // OFI: Order Flow Imbalance [-1, 1]
280        features.lookback_ofi = Some(if total_vol > f64::EPSILON {
281            (buy_vol - sell_vol) / total_vol
282        } else {
283            0.0
284        });
285
286        // Count imbalance [-1, 1]
287        let total_count = buy_count + sell_count;
288        features.lookback_count_imbalance = Some(if total_count > 0 {
289            (buy_count as f64 - sell_count as f64) / total_count as f64
290        } else {
291            0.0
292        });
293
294        // Duration
295        let first_ts = lookback.first().unwrap().timestamp;
296        let last_ts = lookback.last().unwrap().timestamp;
297        let duration_us = last_ts - first_ts;
298        features.lookback_duration_us = Some(duration_us);
299
300        // Intensity (trades per second)
301        let duration_sec = duration_us as f64 / 1_000_000.0;
302        features.lookback_intensity = Some(if duration_sec > f64::EPSILON {
303            n as f64 / duration_sec
304        } else {
305            n as f64 // Instant window = all trades at once
306        });
307
308        // VWAP
309        // Issue #88: i128 sum to prevent overflow on high-token-count symbols
310        let total_volume_fp: i128 = lookback.iter().map(|t| t.volume.0 as i128).sum();
311        features.lookback_vwap = Some(if total_volume_fp > 0 {
312            let vwap_raw = total_turnover / total_volume_fp;
313            FixedPoint(vwap_raw as i64)
314        } else {
315            FixedPoint(0)
316        });
317
318        // VWAP position within range [0, 1]
319        let (low, high) = lookback.iter().fold((i64::MAX, i64::MIN), |acc, t| {
320            (acc.0.min(t.price.0), acc.1.max(t.price.0))
321        });
322        let range = (high - low) as f64;
323        let vwap_val = features.lookback_vwap.as_ref().map(|v| v.0).unwrap_or(0);
324        features.lookback_vwap_position = Some(if range > f64::EPSILON {
325            (vwap_val - low) as f64 / range
326        } else {
327            0.5 // Flat price = middle position
328        });
329    }
330
331    /// Compute Tier 2 features (5 features, varying min trades)
332    fn compute_tier2_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
333        let n = lookback.len();
334
335        // Kyle's Lambda (min 2 trades)
336        if n >= 2 {
337            features.lookback_kyle_lambda = Some(compute_kyle_lambda(lookback));
338        }
339
340        // Burstiness (min 2 trades for inter-arrival times)
341        if n >= 2 {
342            features.lookback_burstiness = Some(compute_burstiness(lookback));
343        }
344
345        // Volume skewness (min 3 trades)
346        if n >= 3 {
347            let (skew, kurt) = compute_volume_moments(lookback);
348            features.lookback_volume_skew = Some(skew);
349            // Kurtosis requires 4 trades for meaningful estimate
350            if n >= 4 {
351                features.lookback_volume_kurt = Some(kurt);
352            }
353        }
354
355        // Price range (min 1 trade)
356        if n >= 1 {
357            let first_price = lookback.first().unwrap().price.to_f64();
358            let (low, high) = lookback.iter().fold((i64::MAX, i64::MIN), |acc, t| {
359                (acc.0.min(t.price.0), acc.1.max(t.price.0))
360            });
361            let range = (high - low) as f64 / 1e8; // Convert from FixedPoint scale
362            features.lookback_price_range = Some(if first_price > f64::EPSILON {
363                range / first_price
364            } else {
365                0.0
366            });
367        }
368    }
369
370    /// Compute Tier 3 features (4 features, higher min trades)
371    fn compute_tier3_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
372        let n = lookback.len();
373
374        // Collect prices for advanced features
375        let prices: Vec<f64> = lookback.iter().map(|t| t.price.to_f64()).collect();
376
377        // Kaufman Efficiency Ratio (min 2 trades)
378        if n >= 2 {
379            features.lookback_kaufman_er = Some(compute_kaufman_er(&prices));
380        }
381
382        // Garman-Klass volatility (min 1 trade, needs OHLC)
383        if n >= 1 {
384            features.lookback_garman_klass_vol = Some(compute_garman_klass(lookback));
385        }
386
387        // Hurst exponent via DFA (min 64 trades for reliable estimate)
388        if n >= 64 {
389            features.lookback_hurst = Some(compute_hurst_dfa(&prices));
390        }
391
392        // Permutation entropy (min 60 trades for m=3, need 10 * m! = 10 * 6 = 60)
393        if n >= 60 {
394            features.lookback_permutation_entropy = Some(compute_permutation_entropy(&prices));
395        }
396    }
397
398    /// Reset bar boundary tracking (Issue #81)
399    ///
400    /// Called at ouroboros boundaries. Clears bar close indices but preserves
401    /// trade history — trades are still valid lookback data for the first
402    /// bar of the new segment.
403    pub fn reset_bar_boundaries(&mut self) {
404        self.bar_close_indices.clear();
405    }
406
407    /// Clear the trade history (e.g., at ouroboros boundary)
408    pub fn clear(&mut self) {
409        self.trades.clear();
410    }
411
412    /// Get current number of trades in buffer
413    pub fn len(&self) -> usize {
414        self.trades.len()
415    }
416
417    /// Check if buffer is empty
418    pub fn is_empty(&self) -> bool {
419        self.trades.is_empty()
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    // Helper to create test trades
428    fn create_test_snapshot(
429        timestamp: i64,
430        price: f64,
431        volume: f64,
432        is_buyer_maker: bool,
433    ) -> TradeSnapshot {
434        let price_fp = FixedPoint((price * 1e8) as i64);
435        let volume_fp = FixedPoint((volume * 1e8) as i64);
436        TradeSnapshot {
437            timestamp,
438            price: price_fp,
439            volume: volume_fp,
440            is_buyer_maker,
441            turnover: (price_fp.0 as i128) * (volume_fp.0 as i128),
442        }
443    }
444
445    // ========== OFI Tests ==========
446
447    #[test]
448    fn test_ofi_all_buys() {
449        let mut history = TradeHistory::new(InterBarConfig::default());
450
451        // Add buy trades (is_buyer_maker = false = buy pressure)
452        for i in 0..10 {
453            let trade = AggTrade {
454                agg_trade_id: i,
455                price: FixedPoint(5000000000000), // 50000
456                volume: FixedPoint(100000000),    // 1.0
457                first_trade_id: i,
458                last_trade_id: i,
459                timestamp: i * 1000,
460                is_buyer_maker: false, // Buy
461                is_best_match: None,
462            };
463            history.push(&trade);
464        }
465
466        let features = history.compute_features(10000);
467
468        assert!(
469            (features.lookback_ofi.unwrap() - 1.0).abs() < f64::EPSILON,
470            "OFI should be 1.0 for all buys, got {}",
471            features.lookback_ofi.unwrap()
472        );
473    }
474
475    #[test]
476    fn test_ofi_all_sells() {
477        let mut history = TradeHistory::new(InterBarConfig::default());
478
479        // Add sell trades (is_buyer_maker = true = sell pressure)
480        for i in 0..10 {
481            let trade = AggTrade {
482                agg_trade_id: i,
483                price: FixedPoint(5000000000000),
484                volume: FixedPoint(100000000),
485                first_trade_id: i,
486                last_trade_id: i,
487                timestamp: i * 1000,
488                is_buyer_maker: true, // Sell
489                is_best_match: None,
490            };
491            history.push(&trade);
492        }
493
494        let features = history.compute_features(10000);
495
496        assert!(
497            (features.lookback_ofi.unwrap() - (-1.0)).abs() < f64::EPSILON,
498            "OFI should be -1.0 for all sells, got {}",
499            features.lookback_ofi.unwrap()
500        );
501    }
502
503    #[test]
504    fn test_ofi_balanced() {
505        let mut history = TradeHistory::new(InterBarConfig::default());
506
507        // Add equal buy and sell volumes
508        for i in 0..10 {
509            let trade = AggTrade {
510                agg_trade_id: i,
511                price: FixedPoint(5000000000000),
512                volume: FixedPoint(100000000),
513                first_trade_id: i,
514                last_trade_id: i,
515                timestamp: i * 1000,
516                is_buyer_maker: i % 2 == 0, // Alternating
517                is_best_match: None,
518            };
519            history.push(&trade);
520        }
521
522        let features = history.compute_features(10000);
523
524        assert!(
525            features.lookback_ofi.unwrap().abs() < f64::EPSILON,
526            "OFI should be 0.0 for balanced volumes, got {}",
527            features.lookback_ofi.unwrap()
528        );
529    }
530
531    // ========== Burstiness Tests ==========
532
533    #[test]
534    fn test_burstiness_regular_intervals() {
535        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
536        let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
537        let t2 = create_test_snapshot(2000, 100.0, 1.0, false);
538        let t3 = create_test_snapshot(3000, 100.0, 1.0, false);
539        let t4 = create_test_snapshot(4000, 100.0, 1.0, false);
540        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3, &t4];
541
542        let b = compute_burstiness(&lookback);
543
544        // Perfectly regular: sigma = 0 -> B = -1
545        assert!(
546            (b - (-1.0)).abs() < 0.01,
547            "Burstiness should be -1 for regular intervals, got {}",
548            b
549        );
550    }
551
552    // ========== Kaufman ER Tests ==========
553
554    #[test]
555    fn test_kaufman_er_perfect_trend() {
556        let prices = vec![100.0, 101.0, 102.0, 103.0, 104.0];
557        let er = compute_kaufman_er(&prices);
558
559        assert!(
560            (er - 1.0).abs() < f64::EPSILON,
561            "Kaufman ER should be 1.0 for perfect trend, got {}",
562            er
563        );
564    }
565
566    #[test]
567    fn test_kaufman_er_round_trip() {
568        let prices = vec![100.0, 102.0, 104.0, 102.0, 100.0];
569        let er = compute_kaufman_er(&prices);
570
571        assert!(
572            er.abs() < f64::EPSILON,
573            "Kaufman ER should be 0.0 for round trip, got {}",
574            er
575        );
576    }
577
578    // ========== Permutation Entropy Tests ==========
579
580    #[test]
581    fn test_permutation_entropy_monotonic() {
582        // Strictly increasing: only pattern 012 appears -> H = 0
583        let prices: Vec<f64> = (1..=100).map(|i| i as f64).collect();
584        let pe = compute_permutation_entropy(&prices);
585
586        assert!(
587            pe.abs() < f64::EPSILON,
588            "PE should be 0 for monotonic, got {}",
589            pe
590        );
591    }
592
593    // ========== Temporal Integrity Tests ==========
594
595    #[test]
596    fn test_lookback_excludes_current_bar_trades() {
597        let mut history = TradeHistory::new(InterBarConfig::default());
598
599        // Add trades at timestamps 0, 1000, 2000, 3000
600        for i in 0..4 {
601            let trade = AggTrade {
602                agg_trade_id: i,
603                price: FixedPoint(5000000000000),
604                volume: FixedPoint(100000000),
605                first_trade_id: i,
606                last_trade_id: i,
607                timestamp: i * 1000,
608                is_buyer_maker: false,
609                is_best_match: None,
610            };
611            history.push(&trade);
612        }
613
614        // Get lookback for bar opening at timestamp 2000
615        let lookback = history.get_lookback_trades(2000);
616
617        // Should only include trades with timestamp < 2000 (i.e., 0 and 1000)
618        assert_eq!(lookback.len(), 2, "Should have 2 trades before bar open");
619
620        for trade in &lookback {
621            assert!(
622                trade.timestamp < 2000,
623                "Trade at {} should be before bar open at 2000",
624                trade.timestamp
625            );
626        }
627    }
628
629    // ========== Bounded Output Tests ==========
630
631    #[test]
632    fn test_count_imbalance_bounded() {
633        let mut history = TradeHistory::new(InterBarConfig::default());
634
635        // Add random mix of buys and sells
636        for i in 0..100 {
637            let trade = AggTrade {
638                agg_trade_id: i,
639                price: FixedPoint(5000000000000),
640                volume: FixedPoint((i % 10 + 1) * 100000000),
641                first_trade_id: i,
642                last_trade_id: i,
643                timestamp: i * 1000,
644                is_buyer_maker: i % 3 == 0,
645                is_best_match: None,
646            };
647            history.push(&trade);
648        }
649
650        let features = history.compute_features(100000);
651        let imb = features.lookback_count_imbalance.unwrap();
652
653        assert!(
654            imb >= -1.0 && imb <= 1.0,
655            "Count imbalance should be in [-1, 1], got {}",
656            imb
657        );
658    }
659
660    #[test]
661    fn test_vwap_position_bounded() {
662        let mut history = TradeHistory::new(InterBarConfig::default());
663
664        // Add trades at varying prices
665        for i in 0..20 {
666            let price = 50000.0 + (i as f64 * 10.0);
667            let trade = AggTrade {
668                agg_trade_id: i,
669                price: FixedPoint((price * 1e8) as i64),
670                volume: FixedPoint(100000000),
671                first_trade_id: i,
672                last_trade_id: i,
673                timestamp: i * 1000,
674                is_buyer_maker: false,
675                is_best_match: None,
676            };
677            history.push(&trade);
678        }
679
680        let features = history.compute_features(20000);
681        let pos = features.lookback_vwap_position.unwrap();
682
683        assert!(
684            pos >= 0.0 && pos <= 1.0,
685            "VWAP position should be in [0, 1], got {}",
686            pos
687        );
688    }
689
690    #[test]
691    fn test_hurst_soft_clamp_bounded() {
692        // Test with extreme input values
693        // Note: tanh approaches 0 and 1 asymptotically, so we use >= and <=
694        for raw_h in [-10.0, -1.0, 0.0, 0.5, 1.0, 2.0, 10.0] {
695            let clamped = soft_clamp_hurst(raw_h);
696            assert!(
697                clamped >= 0.0 && clamped <= 1.0,
698                "Hurst {} soft-clamped to {} should be in [0, 1]",
699                raw_h,
700                clamped
701            );
702        }
703
704        // Verify 0.5 maps to 0.5 exactly
705        let h_half = soft_clamp_hurst(0.5);
706        assert!(
707            (h_half - 0.5).abs() < f64::EPSILON,
708            "Hurst 0.5 should map to 0.5, got {}",
709            h_half
710        );
711    }
712
713    // ========== Edge Case Tests ==========
714
715    #[test]
716    fn test_empty_lookback() {
717        let history = TradeHistory::new(InterBarConfig::default());
718        let features = history.compute_features(1000);
719
720        assert!(
721            features.lookback_trade_count.is_none() || features.lookback_trade_count == Some(0)
722        );
723    }
724
725    #[test]
726    fn test_single_trade_lookback() {
727        let mut history = TradeHistory::new(InterBarConfig::default());
728
729        let trade = AggTrade {
730            agg_trade_id: 0,
731            price: FixedPoint(5000000000000),
732            volume: FixedPoint(100000000),
733            first_trade_id: 0,
734            last_trade_id: 0,
735            timestamp: 0,
736            is_buyer_maker: false,
737            is_best_match: None,
738        };
739        history.push(&trade);
740
741        let features = history.compute_features(1000);
742
743        assert_eq!(features.lookback_trade_count, Some(1));
744        assert_eq!(features.lookback_duration_us, Some(0)); // Single trade = 0 duration
745    }
746
747    #[test]
748    fn test_kyle_lambda_zero_imbalance() {
749        // Equal buy/sell -> imbalance = 0 -> should return 0, not infinity
750        let t0 = create_test_snapshot(0, 100.0, 1.0, false); // buy
751        let t1 = create_test_snapshot(1000, 102.0, 1.0, true); // sell
752        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1];
753
754        let lambda = compute_kyle_lambda(&lookback);
755
756        assert!(
757            lambda.is_finite(),
758            "Kyle lambda should be finite, got {}",
759            lambda
760        );
761        assert!(
762            lambda.abs() < f64::EPSILON,
763            "Kyle lambda should be 0 for zero imbalance"
764        );
765    }
766
767    // ========== BarRelative Mode Tests (Issue #81) ==========
768
769    /// Helper to create a test AggTrade
770    fn make_trade(id: i64, timestamp: i64) -> AggTrade {
771        AggTrade {
772            agg_trade_id: id,
773            price: FixedPoint(5000000000000), // 50000
774            volume: FixedPoint(100000000),    // 1.0
775            first_trade_id: id,
776            last_trade_id: id,
777            timestamp,
778            is_buyer_maker: false,
779            is_best_match: None,
780        }
781    }
782
783    #[test]
784    fn test_bar_relative_bootstrap_keeps_all_trades() {
785        // Before any bars close, BarRelative should keep all trades
786        let config = InterBarConfig {
787            lookback_mode: LookbackMode::BarRelative(3),
788            compute_tier2: false,
789            compute_tier3: false,
790        };
791        let mut history = TradeHistory::new(config);
792
793        // Push 100 trades without closing any bar
794        for i in 0..100 {
795            history.push(&make_trade(i, i * 1000));
796        }
797
798        assert_eq!(history.len(), 100, "Bootstrap phase should keep all trades");
799    }
800
801    #[test]
802    fn test_bar_relative_prunes_after_bar_close() {
803        let config = InterBarConfig {
804            lookback_mode: LookbackMode::BarRelative(2),
805            compute_tier2: false,
806            compute_tier3: false,
807        };
808        let mut history = TradeHistory::new(config);
809
810        // Bar 1: 10 trades (timestamps 0-9000)
811        for i in 0..10 {
812            history.push(&make_trade(i, i * 1000));
813        }
814        history.on_bar_close(); // total_pushed = 10
815
816        // Bar 2: 20 trades (timestamps 10000-29000)
817        for i in 10..30 {
818            history.push(&make_trade(i, i * 1000));
819        }
820        history.on_bar_close(); // total_pushed = 30
821
822        // Bar 3: 5 trades (timestamps 30000-34000)
823        for i in 30..35 {
824            history.push(&make_trade(i, i * 1000));
825        }
826        history.on_bar_close(); // total_pushed = 35
827
828        // With BarRelative(2), after 3 bar closes we keep trades from last 2 bars:
829        // bar_close_indices = [10, 30, 35] -> keep last 2 -> from index 10 to 35 = 25 trades
830        // But bar 1 trades (0-9) should be pruned, keeping bars 2+3 = 25 trades + bar 3's 5
831        // Actually: bar_close_indices keeps n+1=3 boundaries: [10, 30, 35]
832        // Oldest boundary at [len-n_bars] = [3-2] = index 1 = 30
833        // keep_count = total_pushed(35) - 30 = 5
834        // But wait -- we also have current in-progress trades.
835        // After bar 3 closes with 35 total, and no more pushes:
836        // trades.len() should be <= keep_count from the prune in on_bar_close
837        // The prune happens on each push, and on_bar_close records boundary then
838        // next push triggers prune.
839
840        // Push one more trade to trigger prune with new boundary
841        history.push(&make_trade(35, 35000));
842
843        // Now: bar_close_indices = [10, 30, 35], total_pushed = 36
844        // keep_count = 36 - 30 = 6 (trades from bar 2 boundary onwards)
845        // But we also have protected_until which prevents pruning lookback trades
846        // Without protection set (no on_bar_open called), all trades can be pruned
847        assert!(
848            history.len() <= 26, // 25 from bars 2+3 + 1 new, minus pruned old ones
849            "Should prune old bars, got {} trades",
850            history.len()
851        );
852    }
853
854    #[test]
855    fn test_bar_relative_mixed_bar_sizes() {
856        let config = InterBarConfig {
857            lookback_mode: LookbackMode::BarRelative(2),
858            compute_tier2: false,
859            compute_tier3: false,
860        };
861        let mut history = TradeHistory::new(config);
862
863        // Bar 1: 5 trades
864        for i in 0..5 {
865            history.push(&make_trade(i, i * 1000));
866        }
867        history.on_bar_close();
868
869        // Bar 2: 50 trades
870        for i in 5..55 {
871            history.push(&make_trade(i, i * 1000));
872        }
873        history.on_bar_close();
874
875        // Bar 3: 3 trades
876        for i in 55..58 {
877            history.push(&make_trade(i, i * 1000));
878        }
879        history.on_bar_close();
880
881        // Push one more to trigger prune
882        history.push(&make_trade(58, 58000));
883
884        // With BarRelative(2), after 3 bars:
885        // bar_close_indices has max n+1=3 entries: [5, 55, 58]
886        // Oldest boundary for pruning: [len-n_bars] = [3-2] = index 1 = 55
887        // keep_count = 59 - 55 = 4 (3 from bar 3 + 1 new)
888        // This correctly adapts: bar 2 had 50 trades but bar 3 only had 3
889        assert!(
890            history.len() <= 54, // bar 2 + bar 3 + 1 = 54 max
891            "Mixed bar sizes should prune correctly, got {} trades",
892            history.len()
893        );
894    }
895
896    #[test]
897    fn test_bar_relative_lookback_features_computed() {
898        let config = InterBarConfig {
899            lookback_mode: LookbackMode::BarRelative(3),
900            compute_tier2: false,
901            compute_tier3: false,
902        };
903        let mut history = TradeHistory::new(config);
904
905        // Push 20 trades (timestamps 0-19000)
906        for i in 0..20 {
907            let price = 50000.0 + (i as f64 * 10.0);
908            let trade = AggTrade {
909                agg_trade_id: i,
910                price: FixedPoint((price * 1e8) as i64),
911                volume: FixedPoint(100000000),
912                first_trade_id: i,
913                last_trade_id: i,
914                timestamp: i * 1000,
915                is_buyer_maker: i % 2 == 0,
916                is_best_match: None,
917            };
918            history.push(&trade);
919        }
920        // Close bar 1 at total_pushed=20
921        history.on_bar_close();
922
923        // Simulate bar 2 opening at timestamp 20000
924        history.on_bar_open(20000);
925
926        // Compute features for bar 2 -- should use trades before 20000
927        let features = history.compute_features(20000);
928
929        // All 20 trades are before bar open, should have lookback features
930        assert_eq!(features.lookback_trade_count, Some(20));
931        assert!(features.lookback_ofi.is_some());
932        assert!(features.lookback_intensity.is_some());
933    }
934
935    #[test]
936    fn test_bar_relative_reset_bar_boundaries() {
937        let config = InterBarConfig {
938            lookback_mode: LookbackMode::BarRelative(2),
939            compute_tier2: false,
940            compute_tier3: false,
941        };
942        let mut history = TradeHistory::new(config);
943
944        // Push trades and close a bar
945        for i in 0..10 {
946            history.push(&make_trade(i, i * 1000));
947        }
948        history.on_bar_close();
949
950        assert_eq!(history.bar_close_indices.len(), 1);
951
952        // Reset boundaries (ouroboros)
953        history.reset_bar_boundaries();
954
955        assert!(
956            history.bar_close_indices.is_empty(),
957            "bar_close_indices should be empty after reset"
958        );
959        // Trades should still be there
960        assert_eq!(
961            history.len(),
962            10,
963            "Trades should persist after boundary reset"
964        );
965    }
966
967    #[test]
968    fn test_bar_relative_on_bar_close_limits_indices() {
969        let config = InterBarConfig {
970            lookback_mode: LookbackMode::BarRelative(2),
971            compute_tier2: false,
972            compute_tier3: false,
973        };
974        let mut history = TradeHistory::new(config);
975
976        // Close 5 bars
977        for bar_num in 0..5 {
978            for i in 0..5 {
979                history.push(&make_trade(bar_num * 5 + i, (bar_num * 5 + i) * 1000));
980            }
981            history.on_bar_close();
982        }
983
984        // With BarRelative(2), should keep at most n+1=3 boundaries
985        assert!(
986            history.bar_close_indices.len() <= 3,
987            "Should keep at most n+1 boundaries, got {}",
988            history.bar_close_indices.len()
989        );
990    }
991
992    #[test]
993    fn test_bar_relative_does_not_affect_fixed_count() {
994        // Verify FixedCount mode is unaffected by BarRelative changes
995        let config = InterBarConfig {
996            lookback_mode: LookbackMode::FixedCount(10),
997            compute_tier2: false,
998            compute_tier3: false,
999        };
1000        let mut history = TradeHistory::new(config);
1001
1002        for i in 0..30 {
1003            history.push(&make_trade(i, i * 1000));
1004        }
1005        // on_bar_close should be no-op for FixedCount
1006        history.on_bar_close();
1007
1008        // FixedCount(10) keeps 2*10=20 max
1009        assert!(
1010            history.len() <= 20,
1011            "FixedCount(10) should keep at most 20 trades, got {}",
1012            history.len()
1013        );
1014        assert!(
1015            history.bar_close_indices.is_empty(),
1016            "FixedCount should not track bar boundaries"
1017        );
1018    }
1019
1020    // === Memory efficiency tests (R5) ===
1021
1022    #[test]
1023    fn test_volume_moments_numerical_accuracy() {
1024        // R5: Verify 2-pass fold produces identical results to previous 4-pass.
1025        // Symmetric distribution [1,2,3,4,5] → skewness ≈ 0
1026        let price_fp = FixedPoint((100.0 * 1e8) as i64);
1027        let snapshots: Vec<TradeSnapshot> = (1..=5_i64)
1028            .map(|v| {
1029                let volume_fp = FixedPoint((v as f64 * 1e8) as i64);
1030                TradeSnapshot {
1031                    price: price_fp,
1032                    volume: volume_fp,
1033                    timestamp: v * 1000,
1034                    is_buyer_maker: false,
1035                    turnover: price_fp.0 as i128 * volume_fp.0 as i128,
1036                }
1037            })
1038            .collect();
1039        let refs: Vec<&TradeSnapshot> = snapshots.iter().collect();
1040        let (skew, kurt) = compute_volume_moments(&refs);
1041
1042        // Symmetric uniform-like distribution: skewness should be 0
1043        assert!(
1044            skew.abs() < 1e-10,
1045            "Symmetric distribution should have skewness ≈ 0, got {skew}"
1046        );
1047        // Uniform distribution excess kurtosis = -1.3
1048        assert!(
1049            (kurt - (-1.3)).abs() < 0.1,
1050            "Uniform-like kurtosis should be ≈ -1.3, got {kurt}"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_volume_moments_edge_cases() {
1056        let price_fp = FixedPoint((100.0 * 1e8) as i64);
1057
1058        // n < 3 returns (0, 0)
1059        let v1 = FixedPoint((1.0 * 1e8) as i64);
1060        let v2 = FixedPoint((2.0 * 1e8) as i64);
1061        let s1 = TradeSnapshot {
1062            price: price_fp,
1063            volume: v1,
1064            timestamp: 1000,
1065            is_buyer_maker: false,
1066            turnover: price_fp.0 as i128 * v1.0 as i128,
1067        };
1068        let s2 = TradeSnapshot {
1069            price: price_fp,
1070            volume: v2,
1071            timestamp: 2000,
1072            is_buyer_maker: false,
1073            turnover: price_fp.0 as i128 * v2.0 as i128,
1074        };
1075        let refs: Vec<&TradeSnapshot> = vec![&s1, &s2];
1076        let (skew, kurt) = compute_volume_moments(&refs);
1077        assert_eq!(skew, 0.0, "n < 3 should return 0");
1078        assert_eq!(kurt, 0.0, "n < 3 should return 0");
1079
1080        // All same volume returns (0, 0)
1081        let vol = FixedPoint((5.0 * 1e8) as i64);
1082        let same: Vec<TradeSnapshot> = (0..10_i64)
1083            .map(|i| TradeSnapshot {
1084                price: price_fp,
1085                volume: vol,
1086                timestamp: i * 1000,
1087                is_buyer_maker: false,
1088                turnover: price_fp.0 as i128 * vol.0 as i128,
1089            })
1090            .collect();
1091        let refs: Vec<&TradeSnapshot> = same.iter().collect();
1092        let (skew, kurt) = compute_volume_moments(&refs);
1093        assert_eq!(skew, 0.0, "All same volume should return 0");
1094        assert_eq!(kurt, 0.0, "All same volume should return 0");
1095    }
1096}