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 rayon::join; // Issue #115: Parallelization of Tier 2/3 features
37use smallvec::SmallVec;
38use std::collections::VecDeque;
39use std::sync::LazyLock; // std::sync::LazyLock (stable since Rust 1.80, replaces once_cell)
40
41// Re-export types from interbar_types.rs (Phase 2b extraction)
42pub use crate::interbar_types::{InterBarConfig, InterBarFeatures, LookbackMode, TradeSnapshot};
43
44/// Issue #96 Task #191: Lazy initialization of entropy cache warm-up
45/// Ensures warm-up runs exactly once, on first TradeHistory creation in the process
46static ENTROPY_CACHE_WARMUP: LazyLock<()> = LazyLock::new(|| {
47    crate::entropy_cache_global::warm_up_entropy_cache();
48});
49
50/// Trade history ring buffer for inter-bar feature computation
51#[derive(Debug)]
52pub struct TradeHistory {
53    /// Ring buffer of recent trades
54    trades: VecDeque<TradeSnapshot>,
55    /// Configuration for lookback
56    config: InterBarConfig,
57    /// Timestamp threshold: trades with timestamp < this are protected from pruning.
58    /// Set to the oldest timestamp we might need for lookback computation.
59    /// Updated each time a new bar opens.
60    protected_until: Option<i64>,
61    /// Total number of trades pushed (monotonic counter for BarRelative indexing)
62    total_pushed: usize,
63    /// Indices into total_pushed at which each bar closed (Issue #81).
64    /// `bar_close_indices[i]` = `total_pushed` value when bar i closed.
65    /// Used by `BarRelative` mode to determine how many trades to keep.
66    bar_close_indices: VecDeque<usize>,
67    /// Issue #104: Pushes since last prune check (reduces check frequency)
68    pushes_since_prune_check: usize,
69    /// Issue #104: Maximum safe capacity (computed once at init)
70    max_safe_capacity: usize,
71    /// Issue #96 Task #117: Cache for permutation entropy results
72    /// Avoids redundant computation on identical price sequences
73    /// Uses parking_lot::RwLock for lower-latency locking (Issue #96 Task #124)
74    entropy_cache: std::sync::Arc<parking_lot::RwLock<crate::interbar_math::EntropyCache>>,
75    /// Issue #96 Task #144 Phase 4: Cache for complete inter-bar feature results
76    /// Avoids redundant feature computation for similar trade patterns
77    /// Enabled by default, can be disabled via config
78    feature_result_cache: Option<std::sync::Arc<parking_lot::RwLock<crate::interbar_cache::InterBarFeatureCache>>>,
79    /// Issue #96 Task #155 Phase 1: Adaptive pruning batch size tuning
80    /// Tracks pruning efficiency to adapt batch size dynamically
81    /// Reduces overhead of frequent prune checks when pruning is inefficient
82    adaptive_prune_batch: usize,
83    /// Tracks total trades pruned and prune calls for efficiency measurement
84    prune_stats: (usize, usize), // (trades_pruned, prune_calls)
85    /// Issue #96 Task #163: Cache last binary search result
86    /// Avoids O(log n) binary search when bar_open_time hasn't changed significantly
87    /// Most bars have similar/close timestamps, so cutoff index changes slowly
88    /// Issue #96 Task #58: Removed Arc wrapper (eliminates indirection + atomic refcount overhead)
89    last_binary_search_cache: parking_lot::Mutex<Option<(i64, usize)>>,  // (open_time, cutoff_idx)
90    /// Issue #96 Task #167: Lookahead prediction buffer for binary search optimization
91    /// Tracks last 2 search results to predict next position via timestamp delta trend
92    /// On miss, analyzes trend = (ts_delta) / (idx_delta) to hint next search bounds
93    /// Reduces binary search iterations by 20-40% on trending data patterns
94    /// Issue #96 Task #62: VecDeque for O(1) pop_front (was SmallVec with O(n) remove(0))
95    lookahead_buffer: parking_lot::Mutex<VecDeque<(i64, usize)>>,
96}
97
98/// Cold path: return default inter-bar features for empty lookback
99/// Extracted to improve instruction cache locality on the hot path
100#[cold]
101#[inline(never)]
102fn default_interbar_features() -> InterBarFeatures {
103    InterBarFeatures::default()
104}
105
106impl TradeHistory {
107    /// Create new trade history with given configuration
108    ///
109    /// Uses a local entropy cache (default behavior, backward compatible).
110    /// For multi-symbol workloads, use `new_with_cache()` to provide a shared global cache.
111    pub fn new(config: InterBarConfig) -> Self {
112        Self::new_with_cache(config, None)
113    }
114
115    /// Create new trade history with optional external entropy cache
116    ///
117    /// Issue #145 Phase 2: Multi-Symbol Entropy Cache Sharing
118    ///
119    /// ## Parameters
120    ///
121    /// - `config`: Lookback configuration (FixedCount, FixedWindow, or BarRelative)
122    /// - `external_cache`: Optional shared entropy cache from `get_global_entropy_cache()`
123    ///   - If provided: Uses the shared global cache (recommended for multi-symbol)
124    ///   - If None: Creates a local 128-entry cache (default, backward compatible)
125    ///
126    /// ## Usage
127    ///
128    /// ```ignore
129    /// // Single-symbol: use local cache (default)
130    /// let history = TradeHistory::new(config);
131    ///
132    /// // Multi-symbol: share global cache
133    /// let global_cache = get_global_entropy_cache();
134    /// let history = TradeHistory::new_with_cache(config, Some(global_cache));
135    /// ```
136    ///
137    /// ## Thread Safety
138    ///
139    /// Both local and external caches are thread-safe via Arc<RwLock<>>.
140    /// Multiple processors can safely share the same external cache concurrently.
141    pub fn new_with_cache(
142        config: InterBarConfig,
143        external_cache: Option<std::sync::Arc<parking_lot::RwLock<crate::interbar_math::EntropyCache>>>,
144    ) -> Self {
145        // Issue #96 Task #191: Trigger entropy cache warm-up on first TradeHistory creation
146        // Uses lazy static to ensure it runs exactly once per process
147        let _ = &*ENTROPY_CACHE_WARMUP;
148
149        // Issue #118: Optimized capacity sizing based on lookback config
150        // Reduces memory overhead by 20-30% while maintaining safety margins
151        let capacity = match &config.lookback_mode {
152            LookbackMode::FixedCount(n) => *n, // Exact size (pruning handles overflow)
153            LookbackMode::FixedWindow(_) => 500, // Covers 99% of time-based windows
154            LookbackMode::BarRelative(_) => 1000, // Adaptive pruning scales with bar size
155        };
156        // Issue #104: Compute max safe capacity once to avoid repeated computation
157        let max_safe_capacity = match &config.lookback_mode {
158            LookbackMode::FixedCount(n) => *n * 2,  // 2x safety margin (reduced from 3x)
159            LookbackMode::FixedWindow(_) => 1500,    // Reduced from 3000 (better cache locality)
160            LookbackMode::BarRelative(_) => 2000,    // Reduced from 5000 (adaptive scaling)
161        };
162        // Task #91: Pre-allocate bar_close_indices buffer
163        // Typical lookback: 10-100 bars, so capacity 128 avoids most re-allocations
164        let bar_capacity = match &config.lookback_mode {
165            LookbackMode::BarRelative(n_bars) => (*n_bars + 1).min(128),
166            _ => 128,
167        };
168
169        // Issue #145 Phase 2: Use external cache if provided, otherwise create local
170        let entropy_cache = external_cache.unwrap_or_else(|| {
171            std::sync::Arc::new(parking_lot::RwLock::new(crate::interbar_math::EntropyCache::new()))
172        });
173
174        // Issue #96 Task #144 Phase 4: Create feature result cache (enabled by default)
175        let feature_result_cache = Some(
176            std::sync::Arc::new(parking_lot::RwLock::new(
177                crate::interbar_cache::InterBarFeatureCache::new()
178            ))
179        );
180
181        // Issue #96 Task #155: Initialize adaptive pruning batch size
182        let initial_prune_batch = match &config.lookback_mode {
183            LookbackMode::FixedCount(n) => std::cmp::max((*n / 10).max(5), 10),
184            _ => 10,
185        };
186
187        Self {
188            trades: VecDeque::with_capacity(capacity),
189            config,
190            protected_until: None,
191            total_pushed: 0,
192            bar_close_indices: VecDeque::with_capacity(bar_capacity),
193            pushes_since_prune_check: 0,
194            max_safe_capacity,
195            entropy_cache,
196            feature_result_cache,
197            adaptive_prune_batch: initial_prune_batch,
198            prune_stats: (0, 0),
199            last_binary_search_cache: parking_lot::Mutex::new(None), // Issue #96 Task #163/#58: No Arc indirection
200            lookahead_buffer: parking_lot::Mutex::new(VecDeque::with_capacity(3)), // Issue #96 Task #62: VecDeque for O(1) pop_front
201        }
202    }
203
204    /// Push a new trade to the history buffer
205    ///
206    /// Automatically prunes old entries based on lookback mode, but preserves
207    /// trades needed for lookback computation (timestamp < protected_until).
208    /// Issue #104: Uses batched pruning check to reduce frequency
209    pub fn push(&mut self, trade: &AggTrade) {
210        let snapshot = TradeSnapshot::from(trade);
211        self.trades.push_back(snapshot);
212        self.total_pushed += 1;
213        self.pushes_since_prune_check += 1;
214
215        // Issue #96 Task #155: Use adaptive pruning batch size
216        // Batch size increases if pruning is inefficient (<10% trades removed)
217        let prune_batch_size = self.adaptive_prune_batch;
218
219        // Check every N trades or when capacity limit exceeded (deferred: 2x threshold)
220        if self.pushes_since_prune_check >= prune_batch_size
221            || self.trades.len() > self.max_safe_capacity * 2
222        {
223            let trades_before = self.trades.len();
224            self.prune_if_needed();
225            let trades_after = self.trades.len();
226            let trades_removed = trades_before.saturating_sub(trades_after);
227
228            // Issue #96 Task #155: Track efficiency and adapt batch size
229            self.prune_stats.0 = self.prune_stats.0.saturating_add(trades_removed);
230            self.prune_stats.1 = self.prune_stats.1.saturating_add(1);
231
232            // Every 10 prune calls, reevaluate batch size
233            if self.prune_stats.1 > 0 && self.prune_stats.1.is_multiple_of(10) {
234                let avg_removed = self.prune_stats.0 / self.prune_stats.1;
235                let removal_efficiency = if trades_before > 0 {
236                    (avg_removed * 100) / (trades_before + avg_removed)
237                } else {
238                    0
239                };
240
241                // If removing <10%, increase batch size (reduce prune frequency)
242                if removal_efficiency < 10 {
243                    self.adaptive_prune_batch = std::cmp::min(
244                        self.adaptive_prune_batch * 2,
245                        self.max_safe_capacity / 4, // Cap at quarter of max capacity
246                    );
247                } else if removal_efficiency > 30 {
248                    // If removing >30%, decrease batch size (more frequent pruning)
249                    self.adaptive_prune_batch = std::cmp::max(
250                        self.adaptive_prune_batch / 2,
251                        5, // Minimum batch size
252                    );
253                }
254
255                // Reset stats for next measurement cycle
256                self.prune_stats = (0, 0);
257            }
258
259            self.pushes_since_prune_check = 0;
260        }
261    }
262
263    /// Notify that a new bar has opened at the given timestamp
264    ///
265    /// This sets the protection threshold to ensure trades from before the bar
266    /// opened are preserved for lookback computation. The protection extends
267    /// until the next bar opens and calls this method again.
268    pub fn on_bar_open(&mut self, bar_open_time: i64) {
269        // Protect all trades with timestamp < bar_open_time
270        // These are the trades that can be used for lookback computation
271        self.protected_until = Some(bar_open_time);
272    }
273
274    /// Notify that the current bar has closed
275    ///
276    /// For `BarRelative` mode, records the current trade count as a bar boundary.
277    /// For other modes, this is a no-op. Protection is always kept until the
278    /// next bar opens.
279    pub fn on_bar_close(&mut self) {
280        // Record bar boundary for BarRelative pruning (Issue #81)
281        if let LookbackMode::BarRelative(n_bars) = &self.config.lookback_mode {
282            self.bar_close_indices.push_back(self.total_pushed);
283            // Keep only last n_bars+1 boundaries (n_bars for lookback + 1 for current)
284            while self.bar_close_indices.len() > *n_bars + 1 {
285                self.bar_close_indices.pop_front();
286            }
287        }
288        // Keep protection until next bar opens (all modes)
289    }
290
291    /// Conditionally prune trades based on capacity (Task #91: reduce prune() call overhead)
292    ///
293    /// Only calls the full prune() when approaching capacity limits.
294    /// This reduces function call overhead while maintaining correctness.
295    /// Issue #104: Use pre-computed max_safe_capacity for branch-free check
296    fn prune_if_needed(&mut self) {
297        // Issue #104: Simple threshold check using pre-computed capacity
298        // Reduces function call overhead and enables better branch prediction
299        if self.trades.len() > self.max_safe_capacity {
300            self.prune();
301        }
302    }
303
304    /// Prune old trades based on lookback configuration
305    ///
306    /// Pruning logic:
307    /// - For `FixedCount(n)`: Keep up to 2*n trades total, but never prune trades
308    ///   with timestamp < `protected_until` (needed for lookback)
309    /// - For `FixedWindow`: Standard time-based pruning, but respect `protected_until`
310    /// - For `BarRelative(n)`: Keep trades from last n completed bars (Issue #81)
311    fn prune(&mut self) {
312        match &self.config.lookback_mode {
313            LookbackMode::FixedCount(n) => {
314                // Keep at most 2*n trades (n for lookback + n for next bar's lookback)
315                let max_trades = *n * 2;
316                while self.trades.len() > max_trades {
317                    // Check if front trade is protected
318                    if let Some(front) = self.trades.front() {
319                        if let Some(protected) = self.protected_until {
320                            if front.timestamp < protected {
321                                // Don't prune protected trades
322                                break;
323                            }
324                        }
325                    }
326                    self.trades.pop_front();
327                }
328            }
329            LookbackMode::FixedWindow(window_us) => {
330                // Find the oldest trade we need
331                let newest_timestamp = self.trades.back().map(|t| t.timestamp).unwrap_or(0);
332                let cutoff = newest_timestamp - window_us;
333
334                while let Some(front) = self.trades.front() {
335                    // Respect protection
336                    if let Some(protected) = self.protected_until {
337                        if front.timestamp < protected {
338                            break;
339                        }
340                    }
341                    // Prune if outside time window
342                    if front.timestamp < cutoff {
343                        self.trades.pop_front();
344                    } else {
345                        break;
346                    }
347                }
348            }
349            LookbackMode::BarRelative(n_bars) => {
350                // Issue #81: Keep trades from last n completed bars.
351                //
352                // bar_close_indices stores total_pushed at each bar close:
353                //   B0 = end of bar 0 / start of bar 1's trades
354                //   B1 = end of bar 1 / start of bar 2's trades
355                //   etc.
356                //
357                // To include N bars of lookback, we need boundary B_{k-1}
358                // where k is the oldest bar we want. on_bar_close() keeps
359                // at most n_bars+1 entries, so after steady state, front()
360                // is exactly B_{k-1}.
361                //
362                // Bootstrap: when fewer than n_bars bars have closed, we
363                // want ALL available bars, so keep everything.
364                if self.bar_close_indices.len() <= *n_bars {
365                    // Bootstrap: fewer completed bars than lookback depth.
366                    // Keep all trades — we want every available bar.
367                    return;
368                }
369
370                // Steady state: front() is the boundary BEFORE the oldest
371                // bar we want. Trades from front() onward belong to the
372                // N-bar lookback window plus the current in-progress bar.
373                let oldest_boundary = self.bar_close_indices.front().copied().unwrap_or(0);
374                let keep_count = self.total_pushed - oldest_boundary;
375
376                // Prune unconditionally — bar boundaries are the source of truth
377                while self.trades.len() > keep_count {
378                    self.trades.pop_front();
379                }
380            }
381        }
382    }
383
384    /// Fast-path check for empty lookback window (Issue #96 Task #178)
385    ///
386    /// Returns true if there are any lookback trades before the given bar_open_time.
387    /// This check is done WITHOUT allocating the SmallVec, enabling fast-path for
388    /// zero-trade lookback windows. Typical improvement: 0.3-0.8% for windows with
389    /// no lookback data (common in consolidation periods at session start).
390    ///
391    /// # Performance
392    /// - Cache hit: ~2-3 ns (checks cached_idx from previous query)
393    /// - Cache miss: ~5-10 ns (single timestamp comparison, no SmallVec allocation)
394    /// - vs SmallVec allocation: ~10-20 ns (stack buffer initialization)
395    ///
396    /// # Example
397    /// ```ignore
398    /// if history.has_lookback_trades(bar_open_time) {
399    ///     let lookback = history.get_lookback_trades(bar_open_time);
400    ///     // Process lookback
401    /// } else {
402    ///     // Skip feature computation for zero-trade window
403    /// }
404    /// ```
405    #[inline]
406    pub fn has_lookback_trades(&self, bar_open_time: i64) -> bool {
407        // Quick check: if no trades at all, no lookback
408        if self.trades.is_empty() {
409            return false;
410        }
411
412        // Check cache first for O(1) path (Issue #96 Task #163/#58)
413        {
414            let cache = self.last_binary_search_cache.lock();
415            if let Some((cached_time, cached_idx)) = *cache {
416                if cached_time == bar_open_time {
417                    return cached_idx > 0;
418                }
419            }
420        }
421
422        // Cache miss: use partition_point for cleaner cutoff lookup
423        // Issue #96 Task #48: partition_point avoids Ok/Err unwrapping overhead
424        let idx = self.trades.partition_point(|trade| trade.timestamp < bar_open_time);
425        *self.last_binary_search_cache.lock() = Some((bar_open_time, idx));
426        idx > 0
427    }
428
429    /// Analyze lookahead buffer to compute trend-based search hint
430    ///
431    /// Issue #96 Task #167 Phase 2: Uses last 2-3 search results to predict if the
432    /// next index will be higher or lower than the previous result. Enables partitioned
433    /// binary search for 5-10% iteration reduction on trending data.
434    ///
435    /// Returns (should_check_higher, last_index) if trend is reliable, None otherwise
436    fn compute_search_hint(&self) -> Option<(bool, usize)> {
437        let buffer = self.lookahead_buffer.lock();
438        if buffer.len() < 2 {
439            return None;
440        }
441
442        // Compute trend from last 2 results
443        let prev = buffer[buffer.len() - 2]; // (ts, idx)
444        let curr = buffer[buffer.len() - 1];
445
446        let ts_delta = curr.0.saturating_sub(prev.0);
447        let idx_delta = (curr.1 as i64) - (prev.1 as i64);
448
449        // Only use hint if trend is clear (not flat, indices are changing)
450        if ts_delta > 0 && idx_delta != 0 {
451            let should_check_higher = idx_delta > 0;
452            Some((should_check_higher, curr.1))
453        } else {
454            None
455        }
456    }
457
458    pub fn get_lookback_trades(&self, bar_open_time: i64) -> SmallVec<[&TradeSnapshot; 256]> {
459        // Issue #96 Task #163/#58: Check cache first
460        {
461            let cache = self.last_binary_search_cache.lock();
462            if let Some((cached_time, cached_idx)) = *cache {
463                if cached_time == bar_open_time {
464                    let cutoff_idx = cached_idx;
465                    drop(cache);
466                    let mut result = SmallVec::new();
467                    for i in 0..cutoff_idx {
468                        result.push(&self.trades[i]);
469                    }
470                    return result;
471                }
472            }
473        }
474
475        // Issue #96 Task #167 Phase 2: Trend-guided binary search with lookahead hint
476        // Uses hint for O(1) boundary probe; falls back to O(log n) VecDeque binary search.
477        // Issue #96 Task #48: partition_point replaces binary_search_by + Ok/Err collapse
478        #[inline(always)]
479        fn ts_partition_point(trades: &std::collections::VecDeque<TradeSnapshot>, bar_open_time: i64) -> usize {
480            trades.partition_point(|trade| trade.timestamp < bar_open_time)
481        }
482
483        let cutoff_idx = if let Some((should_check_higher, last_idx)) = self.compute_search_hint() {
484            let check_region_end = if should_check_higher {
485                std::cmp::min(last_idx + (last_idx / 2), self.trades.len())
486            } else {
487                last_idx
488            };
489
490            // O(1) boundary probe: if all trades are before bar_open_time, skip binary search
491            if check_region_end > 0
492                && check_region_end == self.trades.len()
493                && self.trades[check_region_end - 1].timestamp < bar_open_time
494            {
495                check_region_end
496            } else {
497                ts_partition_point(&self.trades, bar_open_time)
498            }
499        } else {
500            ts_partition_point(&self.trades, bar_open_time)
501        };
502
503        // Issue #96 Task #163/#58: Update cache
504        *self.last_binary_search_cache.lock() = Some((bar_open_time, cutoff_idx));
505
506        // Issue #96 Task #167/#58: Update lookahead buffer
507        {
508            let mut buffer = self.lookahead_buffer.lock();
509            buffer.push_back((bar_open_time, cutoff_idx));
510            if buffer.len() > 3 {
511                buffer.pop_front();
512            }
513        }
514
515        // Task #26: Unified loop handles all sizes (0 = no iterations, no special cases needed)
516        let mut result = SmallVec::new();
517        for i in 0..cutoff_idx {
518            result.push(&self.trades[i]);
519        }
520        result
521    }
522
523    /// Get buffer statistics for benchmarking and profiling
524    ///
525    /// Issue #96 Task #155: Exposes pruning state for performance analysis
526    pub fn buffer_stats(&self) -> (usize, usize, usize, usize) {
527        (
528            self.trades.len(),
529            self.max_safe_capacity,
530            self.adaptive_prune_batch,
531            self.prune_stats.0, // trades_pruned
532        )
533    }
534
535    /// Compute inter-bar features from lookback window
536    ///
537    /// Issue #96 Task #99: Memoized float conversions for 2-5% speedup
538    /// Extracts prices/volumes once and reuses across all 16 features.
539    ///
540    /// # Arguments
541    ///
542    /// * `bar_open_time` - The open timestamp of the current bar (microseconds)
543    ///
544    /// # Returns
545    ///
546    /// `InterBarFeatures` with computed values, or `None` for features that
547    /// cannot be computed due to insufficient data.
548    pub fn compute_features(&self, bar_open_time: i64) -> InterBarFeatures {
549        // Issue #96 Task #44: Eliminate double binary search in compute_features
550        // Previously: has_lookback_trades() (mutex lock + binary search + cache update)
551        //   then get_lookback_trades() (mutex lock + cache hit + SmallVec construction)
552        // Now: trades.is_empty() O(1) fast-path + single get_lookback_trades() call
553        // Saves 1 mutex lock acquisition per bar (~0.3-0.5% speedup)
554        if self.trades.is_empty() {
555            return default_interbar_features();
556        }
557
558        let lookback = self.get_lookback_trades(bar_open_time);
559
560        if lookback.is_empty() {
561            return default_interbar_features();
562        }
563
564        // Issue #96 Task #183: Check feature result cache with try-lock to reduce contention
565        // Task #15: Compute cache key once, reuse for both read and write paths
566        let cache_key = self.feature_result_cache.as_ref().map(|_| {
567            crate::interbar_cache::InterBarCacheKey::from_lookback(&lookback)
568        });
569        if let (Some(cache), Some(key)) = (&self.feature_result_cache, &cache_key) {
570            if let Some(cache_guard) = cache.try_read() {
571                if let Some(cached_features) = cache_guard.get(key) {
572                    return cached_features;
573                }
574                drop(cache_guard);
575            }
576        }
577
578        let mut features = InterBarFeatures::default();
579
580        // === Tier 1: Core Features ===
581        self.compute_tier1_features(&lookback, &mut features);
582
583        // === Issue #96 Task #99: Single-pass cache extraction ===
584        // Pre-compute all float conversions once, before any Tier 2/3 features
585        let cache = if self.config.compute_tier2 || self.config.compute_tier3 {
586            Some(crate::interbar_math::extract_lookback_cache(&lookback))
587        } else {
588            None
589        };
590
591        // === Tier 2 & 3: Dynamic Parallelization with CPU-Aware Dispatch (Issue #96 Task #189) ===
592        // Adaptive dispatch based on window size, tier complexity, and CPU availability
593        // Tier 2: Lower threshold (simpler computation, parallelization benefits earlier)
594        // Tier 3: Higher threshold (complex computation, parallelization justified for larger windows)
595        // CPU-aware: Avoid oversubscription on systems with few cores
596
597        // Issue #96 Task #189: Dynamic threshold calculation
598        // Base thresholds: Tier 2 can parallelize with fewer trades than Tier 3
599        const TIER2_PARALLEL_THRESHOLD_BASE: usize = 80;   // Tier 2 parallelizes at 80+ trades
600        const TIER3_PARALLEL_THRESHOLD_BASE: usize = 150;  // Tier 3 parallelizes at 150+ trades
601
602        // Task #18: Cache CPU count to avoid repeated syscall per bar
603        static CPU_COUNT: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
604        let cpu_count = *CPU_COUNT.get_or_init(num_cpus::get);
605        let tier2_threshold = if cpu_count == 1 {
606            usize::MAX  // Never parallelize on single-core
607        } else {
608            TIER2_PARALLEL_THRESHOLD_BASE / cpu_count.max(2)
609        };
610
611        let tier3_threshold = if cpu_count == 1 {
612            usize::MAX  // Never parallelize on single-core
613        } else {
614            TIER3_PARALLEL_THRESHOLD_BASE / cpu_count.max(2)
615        };
616
617        // Dispatch Tier 2 & 3 with independent parallelization decisions
618        let tier2_can_parallelize = self.config.compute_tier2 && lookback.len() >= tier2_threshold;
619        let tier3_can_parallelize = self.config.compute_tier3 && lookback.len() >= tier3_threshold;
620
621        match (tier2_can_parallelize, tier3_can_parallelize) {
622            // Both parallelizable: use rayon join for both
623            (true, true) => {
624                let (tier2_features, tier3_features) = join(
625                    || self.compute_tier2_features(&lookback, cache.as_ref()),
626                    || self.compute_tier3_features(&lookback, cache.as_ref()),
627                );
628                features.merge_tier2(&tier2_features);
629                features.merge_tier3(&tier3_features);
630            }
631            // Only Tier 2 parallelizable: parallel Tier 2, sequential Tier 3
632            (true, false) => {
633                let tier2_features = self.compute_tier2_features(&lookback, cache.as_ref());
634                features.merge_tier2(&tier2_features);
635                if self.config.compute_tier3 {
636                    let tier3_features = self.compute_tier3_features(&lookback, cache.as_ref());
637                    features.merge_tier3(&tier3_features);
638                }
639            }
640            // Only Tier 3 parallelizable: sequential Tier 2, parallel Tier 3
641            (false, true) => {
642                if self.config.compute_tier2 {
643                    let tier2_features = self.compute_tier2_features(&lookback, cache.as_ref());
644                    features.merge_tier2(&tier2_features);
645                }
646                let tier3_features = self.compute_tier3_features(&lookback, cache.as_ref());
647                features.merge_tier3(&tier3_features);
648            }
649            // Neither parallelizable: sequential for both
650            (false, false) => {
651                if self.config.compute_tier2 {
652                    let tier2_features = self.compute_tier2_features(&lookback, cache.as_ref());
653                    features.merge_tier2(&tier2_features);
654                }
655                if self.config.compute_tier3 {
656                    let tier3_features = self.compute_tier3_features(&lookback, cache.as_ref());
657                    features.merge_tier3(&tier3_features);
658                }
659            }
660        }
661
662        // Issue #96 Task #183: Store computed features in cache with try-write
663        // Task #15: Reuse cache_key computed above (avoids duplicate from_lookback call)
664        if let (Some(cache), Some(key)) = (&self.feature_result_cache, cache_key) {
665            if let Some(cache_guard) = cache.try_write() {
666                cache_guard.insert(key, features);
667            }
668        }
669
670        features
671    }
672
673    /// Compute Tier 1 features (7 features, min 1 trade)
674    /// Issue #96 Task #85: #[inline] — called once per bar from compute_features()
675    #[inline]
676    fn compute_tier1_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
677        let n = lookback.len();
678        if n == 0 {
679            return;
680        }
681
682        // Trade count
683        features.lookback_trade_count = Some(n as u32);
684
685        // Issue #96 Task #46: Merge Tier 1 feature folds into single pass
686        // Previously: 3 separate folds (buy/sell, volumes, min/max prices)
687        // Now: Single loop with 8-value accumulation - 8-15% speedup via improved cache locality
688        // Issue #96: Branchless buy/sell accumulation eliminates branch mispredictions
689        let mut buy_vol = 0.0_f64;
690        let mut sell_vol = 0.0_f64;
691        let mut buy_count = 0_u32;
692        let mut sell_count = 0_u32;
693        let mut total_turnover = 0_i128;
694        let mut total_volume_fp = 0_i128;
695        let mut low = i64::MAX;
696        let mut high = i64::MIN;
697
698        for t in lookback.iter() {
699            total_turnover += t.turnover;
700            total_volume_fp += t.volume.0 as i128;
701            low = low.min(t.price.0);
702            high = high.max(t.price.0);
703
704            // Branchless buy/sell accumulation: mask-based arithmetic
705            // is_buyer_maker=true → seller (mask=1.0), false → buyer (mask=0.0)
706            let vol = t.volume.to_f64();
707            let is_seller_mask = t.is_buyer_maker as u32 as f64;
708            sell_vol += vol * is_seller_mask;
709            buy_vol += vol * (1.0 - is_seller_mask);
710
711            let is_seller_count = t.is_buyer_maker as u32;
712            sell_count += is_seller_count;
713            buy_count += 1 - is_seller_count;
714        }
715
716        let total_vol = buy_vol + sell_vol;
717
718        // OFI: Order Flow Imbalance [-1, 1]
719        features.lookback_ofi = Some(if total_vol > f64::EPSILON {
720            (buy_vol - sell_vol) / total_vol
721        } else {
722            0.0
723        });
724
725        // Count imbalance [-1, 1]
726        let total_count = buy_count + sell_count;
727        features.lookback_count_imbalance = Some(if total_count > 0 {
728            (buy_count as f64 - sell_count as f64) / total_count as f64
729        } else {
730            0.0
731        });
732
733        // Duration
734        // Issue #96 Task #86: Direct indexing — n>0 guaranteed by early return above
735        let first_ts = lookback[0].timestamp;
736        let last_ts = lookback[n - 1].timestamp;
737        let duration_us = last_ts - first_ts;
738        features.lookback_duration_us = Some(duration_us);
739
740        // Intensity (trades per second)
741        // Issue #96: Multiply by reciprocal instead of dividing
742        let duration_sec = duration_us as f64 * 1e-6;
743        features.lookback_intensity = Some(if duration_sec > f64::EPSILON {
744            n as f64 / duration_sec
745        } else {
746            n as f64 // Instant window = all trades at once
747        });
748
749        // VWAP (Issue #88: i128 sum to prevent overflow on high-token-count symbols)
750        features.lookback_vwap = Some(if total_volume_fp > 0 {
751            let vwap_raw = total_turnover / total_volume_fp;
752            FixedPoint(vwap_raw as i64)
753        } else {
754            FixedPoint(0)
755        });
756
757        // VWAP position within range [0, 1]
758        let range = (high - low) as f64;
759        let vwap_val = features.lookback_vwap.as_ref().map(|v| v.0).unwrap_or(0);
760        features.lookback_vwap_position = Some(if range > f64::EPSILON {
761            (vwap_val - low) as f64 / range
762        } else {
763            0.5 // Flat price = middle position
764        });
765    }
766
767    /// Compute Tier 2 features (5 features, varying min trades)
768    ///
769    /// Issue #96 Task #99: Optimized with memoized float conversions.
770    /// Uses pre-computed cache passed from compute_features() to avoid
771    /// redundant float conversions across multiple feature functions.
772    /// Issue #115: Refactored to return InterBarFeatures for rayon parallelization support
773    /// Issue #96 Task #85: #[inline] — called once per bar from compute_features()
774    #[inline]
775    fn compute_tier2_features(
776        &self,
777        lookback: &[&TradeSnapshot],
778        cache: Option<&crate::interbar_math::LookbackCache>,
779    ) -> InterBarFeatures {
780        let mut features = InterBarFeatures::default();
781        let n = lookback.len();
782
783        // Issue #96 Task #187: Eliminate redundant SmallVec clone
784        // Use cache reference directly if provided, only extract on cache miss (rare)
785        // Avoids cloning ~400-2000 f64 values per tier computation
786        let cache_owned;
787        let cache = match cache {
788            Some(c) => c,  // Fast path: use reference directly (no clone)
789            None => {
790                // Slow path: extract only when not provided
791                cache_owned = crate::interbar_math::extract_lookback_cache(lookback);
792                &cache_owned
793            }
794        };
795
796        // Kyle's Lambda (min 2 trades)
797        if n >= 2 {
798            features.lookback_kyle_lambda = Some(compute_kyle_lambda(lookback));
799        }
800
801        // Burstiness (min 2 trades for inter-arrival times)
802        if n >= 2 {
803            features.lookback_burstiness = Some(compute_burstiness(lookback));
804        }
805
806        // Volume skewness (min 3 trades)
807        // Issue #96 Task #51: Use pre-computed total_volume for mean (eliminates O(n) sum pass)
808        if n >= 3 {
809            let mean_vol = cache.total_volume / n as f64;
810            let (skew, kurt) = crate::interbar_math::compute_volume_moments_with_mean(&cache.volumes, mean_vol);
811            features.lookback_volume_skew = Some(skew);
812            // Kurtosis requires 4 trades for meaningful estimate
813            if n >= 4 {
814                features.lookback_volume_kurt = Some(kurt);
815            }
816        }
817
818        // Price range (min 1 trade)
819        // Issue #96 Task #99: Use cached open (first price) and OHLC instead of conversion + fold
820        if n >= 1 {
821            let range = cache.high - cache.low;
822            features.lookback_price_range = Some(if cache.open > f64::EPSILON {
823                range / cache.open
824            } else {
825                0.0
826            });
827        }
828
829        features
830    }
831
832    /// Compute Tier 3 features (4 features, higher min trades)
833    ///
834    /// Issue #96 Task #77: Single-pass OHLC + prices extraction for 1.3-1.6x speedup
835    /// Compute Tier 3 features (4 features, higher min trades)
836    ///
837    /// Issue #96 Task #77: Single-pass OHLC + prices extraction for 1.3-1.6x speedup
838    /// Combines price collection with OHLC computation (eliminates double-pass)
839    /// Issue #96 Task #10: SmallVec optimization for price allocation (typical 100-500 trades)
840    /// Issue #96 Task #99: Reuses memoized float conversions from shared cache
841    /// Issue #115: Refactored to return InterBarFeatures for rayon parallelization support
842    /// Issue #96 Task #85: #[inline] — called once per bar from compute_features()
843    #[inline]
844    fn compute_tier3_features(
845        &self,
846        lookback: &[&TradeSnapshot],
847        cache: Option<&crate::interbar_math::LookbackCache>,
848    ) -> InterBarFeatures {
849        let mut features = InterBarFeatures::default();
850        let n = lookback.len();
851
852        // Issue #96 Task #187: Eliminate redundant SmallVec clone
853        // Use cache reference directly if provided, only extract on cache miss (rare)
854        // Avoids cloning ~400-2000 f64 values per tier computation
855        let cache_owned;
856        let cache = match cache {
857            Some(c) => c,  // Fast path: use reference directly (no clone)
858            None => {
859                // Slow path: extract only when not provided
860                cache_owned = crate::interbar_math::extract_lookback_cache(lookback);
861                &cache_owned
862            }
863        };
864        // Issue #110: Avoid cloning prices - all Tier 3 functions accept &[f64]
865        let prices = &cache.prices;
866        let (open, high, low, close) = (cache.open, cache.high, cache.low, cache.close);
867
868        // Issue #96 Task #206: Early validity checks on price data
869        // Skip Tier 3 computation if price data is invalid (NaN or degenerate)
870        // Issue #96 Task #45: Use pre-computed all_prices_finite flag (O(1) vs O(n) scan)
871        if prices.is_empty() || !cache.all_prices_finite {
872            return features;  // Return default (all None) for invalid prices
873        }
874
875        // Kaufman Efficiency Ratio (min 2 trades)
876        if n >= 2 {
877            features.lookback_kaufman_er = Some(compute_kaufman_er(prices));
878        }
879
880        // Garman-Klass volatility (min 1 trade) - use batch OHLC data
881        if n >= 1 {
882            features.lookback_garman_klass_vol = Some(compute_garman_klass_with_ohlc(open, high, low, close));
883        }
884
885        // Entropy: adaptive switching with caching (Issue #96 Task #7 + Task #117)
886        // - Small windows (n < 500): Permutation Entropy with caching (Issue #96 Task #117)
887        // - Large windows (n >= 500): Approximate Entropy (5-10x faster on large n)
888        // Minimum 60 trades for permutation entropy (m=3, need 10 * m! = 60)
889        // MUST compute entropy before Hurst for early-exit gating (Issue #96 Task #160)
890        let mut entropy_value: Option<f64> = None;
891        if n >= 60 {
892            // Issue #96 Task #156: Try-lock fast-path for entropy cache
893            // Attempt read-lock first to check cache without exclusive access.
894            // Fall back to write-lock only if miss to reduce lock contention overhead.
895            let entropy = if let Some(cache) = self.entropy_cache.try_read() {
896                // Fast path: Read lock acquired, check cache
897                let cache_result = crate::interbar_math::compute_entropy_adaptive_cached_readonly(
898                    prices,
899                    &cache,
900                );
901
902                if let Some(result) = cache_result {
903                    // Cache hit: return immediately without lock
904                    result
905                } else {
906                    // Cache miss: drop read lock and acquire write lock
907                    drop(cache);
908                    let mut cache_guard = self.entropy_cache.write();
909                    crate::interbar_math::compute_entropy_adaptive_cached(prices, &mut cache_guard)
910                }
911            } else {
912                // Contended: fall back to write-lock (rare, preserves correctness)
913                let mut cache_guard = self.entropy_cache.write();
914                crate::interbar_math::compute_entropy_adaptive_cached(prices, &mut cache_guard)
915            };
916
917            entropy_value = Some(entropy);
918            features.lookback_permutation_entropy = Some(entropy);
919        }
920
921        // Issue #96 Task #160: Hurst early-exit via entropy threshold
922        // High-entropy sequences (random walks) inherently have Hurst ≈ 0.5
923        // Early-exit logic: if entropy > 0.75 (high randomness), skip expensive computation
924        // Performance: 30-40% bars skipped in ranging markets (2-4% speedup)
925        if n >= 64 {
926            // Check if entropy is available and indicates high randomness (near random walk)
927            let should_skip_hurst = entropy_value.is_some_and(|e| e > 0.75);
928
929            if should_skip_hurst {
930                // High entropy indicates random walk behavior → Hurst ≈ 0.5
931                // Skipping expensive DFA computation saves ~1-2 µs per bar
932                features.lookback_hurst = Some(0.5);
933            } else {
934                // Low/medium entropy indicates order or mean-reversion → compute Hurst
935                features.lookback_hurst = Some(compute_hurst_dfa(prices));
936            }
937        }
938
939        features
940    }
941
942    /// Reset bar boundary tracking (Issue #81)
943    ///
944    /// Called at ouroboros boundaries. Clears bar close indices but preserves
945    /// trade history — trades are still valid lookback data for the first
946    /// bar of the new segment.
947    pub fn reset_bar_boundaries(&mut self) {
948        self.bar_close_indices.clear();
949    }
950
951    /// Clear the trade history (e.g., at ouroboros boundary)
952    pub fn clear(&mut self) {
953        self.trades.clear();
954    }
955
956    /// Get current number of trades in buffer
957    pub fn len(&self) -> usize {
958        self.trades.len()
959    }
960
961    /// Check if buffer is empty
962    pub fn is_empty(&self) -> bool {
963        self.trades.is_empty()
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    // Helper to create test trades
972    fn create_test_snapshot(
973        timestamp: i64,
974        price: f64,
975        volume: f64,
976        is_buyer_maker: bool,
977    ) -> TradeSnapshot {
978        let price_fp = FixedPoint((price * 1e8) as i64);
979        let volume_fp = FixedPoint((volume * 1e8) as i64);
980        TradeSnapshot {
981            timestamp,
982            price: price_fp,
983            volume: volume_fp,
984            is_buyer_maker,
985            turnover: (price_fp.0 as i128) * (volume_fp.0 as i128),
986        }
987    }
988
989    // ========== OFI Tests ==========
990
991    #[test]
992    fn test_ofi_all_buys() {
993        let mut history = TradeHistory::new(InterBarConfig::default());
994
995        // Add buy trades (is_buyer_maker = false = buy pressure)
996        for i in 0..10 {
997            let trade = AggTrade {
998                agg_trade_id: i,
999                price: FixedPoint(5000000000000), // 50000
1000                volume: FixedPoint(100000000),    // 1.0
1001                first_trade_id: i,
1002                last_trade_id: i,
1003                timestamp: i * 1000,
1004                is_buyer_maker: false, // Buy
1005                is_best_match: None,
1006            };
1007            history.push(&trade);
1008        }
1009
1010        let features = history.compute_features(10000);
1011
1012        assert!(
1013            (features.lookback_ofi.unwrap() - 1.0).abs() < f64::EPSILON,
1014            "OFI should be 1.0 for all buys, got {}",
1015            features.lookback_ofi.unwrap()
1016        );
1017    }
1018
1019    #[test]
1020    fn test_ofi_all_sells() {
1021        let mut history = TradeHistory::new(InterBarConfig::default());
1022
1023        // Add sell trades (is_buyer_maker = true = sell pressure)
1024        for i in 0..10 {
1025            let trade = AggTrade {
1026                agg_trade_id: i,
1027                price: FixedPoint(5000000000000),
1028                volume: FixedPoint(100000000),
1029                first_trade_id: i,
1030                last_trade_id: i,
1031                timestamp: i * 1000,
1032                is_buyer_maker: true, // Sell
1033                is_best_match: None,
1034            };
1035            history.push(&trade);
1036        }
1037
1038        let features = history.compute_features(10000);
1039
1040        assert!(
1041            (features.lookback_ofi.unwrap() - (-1.0)).abs() < f64::EPSILON,
1042            "OFI should be -1.0 for all sells, got {}",
1043            features.lookback_ofi.unwrap()
1044        );
1045    }
1046
1047    #[test]
1048    fn test_ofi_balanced() {
1049        let mut history = TradeHistory::new(InterBarConfig::default());
1050
1051        // Add equal buy and sell volumes
1052        for i in 0..10 {
1053            let trade = AggTrade {
1054                agg_trade_id: i,
1055                price: FixedPoint(5000000000000),
1056                volume: FixedPoint(100000000),
1057                first_trade_id: i,
1058                last_trade_id: i,
1059                timestamp: i * 1000,
1060                is_buyer_maker: i % 2 == 0, // Alternating
1061                is_best_match: None,
1062            };
1063            history.push(&trade);
1064        }
1065
1066        let features = history.compute_features(10000);
1067
1068        assert!(
1069            features.lookback_ofi.unwrap().abs() < f64::EPSILON,
1070            "OFI should be 0.0 for balanced volumes, got {}",
1071            features.lookback_ofi.unwrap()
1072        );
1073    }
1074
1075    // ========== Burstiness Tests ==========
1076
1077    #[test]
1078    fn test_burstiness_regular_intervals() {
1079        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
1080        let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
1081        let t2 = create_test_snapshot(2000, 100.0, 1.0, false);
1082        let t3 = create_test_snapshot(3000, 100.0, 1.0, false);
1083        let t4 = create_test_snapshot(4000, 100.0, 1.0, false);
1084        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3, &t4];
1085
1086        let b = compute_burstiness(&lookback);
1087
1088        // Perfectly regular: sigma = 0 -> B = -1
1089        assert!(
1090            (b - (-1.0)).abs() < 0.01,
1091            "Burstiness should be -1 for regular intervals, got {}",
1092            b
1093        );
1094    }
1095
1096    // ========== Kaufman ER Tests ==========
1097
1098    #[test]
1099    fn test_kaufman_er_perfect_trend() {
1100        let prices = vec![100.0, 101.0, 102.0, 103.0, 104.0];
1101        let er = compute_kaufman_er(&prices);
1102
1103        assert!(
1104            (er - 1.0).abs() < f64::EPSILON,
1105            "Kaufman ER should be 1.0 for perfect trend, got {}",
1106            er
1107        );
1108    }
1109
1110    #[test]
1111    fn test_kaufman_er_round_trip() {
1112        let prices = vec![100.0, 102.0, 104.0, 102.0, 100.0];
1113        let er = compute_kaufman_er(&prices);
1114
1115        assert!(
1116            er.abs() < f64::EPSILON,
1117            "Kaufman ER should be 0.0 for round trip, got {}",
1118            er
1119        );
1120    }
1121
1122    // ========== Permutation Entropy Tests ==========
1123
1124    #[test]
1125    fn test_permutation_entropy_monotonic() {
1126        // Strictly increasing: only pattern 012 appears -> H = 0
1127        let prices: Vec<f64> = (1..=100).map(|i| i as f64).collect();
1128        let pe = compute_permutation_entropy(&prices);
1129
1130        assert!(
1131            pe.abs() < f64::EPSILON,
1132            "PE should be 0 for monotonic, got {}",
1133            pe
1134        );
1135    }
1136
1137    // ========== Temporal Integrity Tests ==========
1138
1139    #[test]
1140    fn test_lookback_excludes_current_bar_trades() {
1141        let mut history = TradeHistory::new(InterBarConfig::default());
1142
1143        // Add trades at timestamps 0, 1000, 2000, 3000
1144        for i in 0..4 {
1145            let trade = AggTrade {
1146                agg_trade_id: i,
1147                price: FixedPoint(5000000000000),
1148                volume: FixedPoint(100000000),
1149                first_trade_id: i,
1150                last_trade_id: i,
1151                timestamp: i * 1000,
1152                is_buyer_maker: false,
1153                is_best_match: None,
1154            };
1155            history.push(&trade);
1156        }
1157
1158        // Get lookback for bar opening at timestamp 2000
1159        let lookback = history.get_lookback_trades(2000);
1160
1161        // Should only include trades with timestamp < 2000 (i.e., 0 and 1000)
1162        assert_eq!(lookback.len(), 2, "Should have 2 trades before bar open");
1163
1164        for trade in &lookback {
1165            assert!(
1166                trade.timestamp < 2000,
1167                "Trade at {} should be before bar open at 2000",
1168                trade.timestamp
1169            );
1170        }
1171    }
1172
1173    // ========== Bounded Output Tests ==========
1174
1175    #[test]
1176    fn test_count_imbalance_bounded() {
1177        let mut history = TradeHistory::new(InterBarConfig::default());
1178
1179        // Add random mix of buys and sells
1180        for i in 0..100 {
1181            let trade = AggTrade {
1182                agg_trade_id: i,
1183                price: FixedPoint(5000000000000),
1184                volume: FixedPoint((i % 10 + 1) * 100000000),
1185                first_trade_id: i,
1186                last_trade_id: i,
1187                timestamp: i * 1000,
1188                is_buyer_maker: i % 3 == 0,
1189                is_best_match: None,
1190            };
1191            history.push(&trade);
1192        }
1193
1194        let features = history.compute_features(100000);
1195        let imb = features.lookback_count_imbalance.unwrap();
1196
1197        assert!(
1198            imb >= -1.0 && imb <= 1.0,
1199            "Count imbalance should be in [-1, 1], got {}",
1200            imb
1201        );
1202    }
1203
1204    #[test]
1205    fn test_vwap_position_bounded() {
1206        let mut history = TradeHistory::new(InterBarConfig::default());
1207
1208        // Add trades at varying prices
1209        for i in 0..20 {
1210            let price = 50000.0 + (i as f64 * 10.0);
1211            let trade = AggTrade {
1212                agg_trade_id: i,
1213                price: FixedPoint((price * 1e8) as i64),
1214                volume: FixedPoint(100000000),
1215                first_trade_id: i,
1216                last_trade_id: i,
1217                timestamp: i * 1000,
1218                is_buyer_maker: false,
1219                is_best_match: None,
1220            };
1221            history.push(&trade);
1222        }
1223
1224        let features = history.compute_features(20000);
1225        let pos = features.lookback_vwap_position.unwrap();
1226
1227        assert!(
1228            pos >= 0.0 && pos <= 1.0,
1229            "VWAP position should be in [0, 1], got {}",
1230            pos
1231        );
1232    }
1233
1234    #[test]
1235    fn test_hurst_soft_clamp_bounded() {
1236        // Test with extreme input values
1237        // Note: tanh approaches 0 and 1 asymptotically, so we use >= and <=
1238        for raw_h in [-10.0, -1.0, 0.0, 0.5, 1.0, 2.0, 10.0] {
1239            let clamped = soft_clamp_hurst(raw_h);
1240            assert!(
1241                clamped >= 0.0 && clamped <= 1.0,
1242                "Hurst {} soft-clamped to {} should be in [0, 1]",
1243                raw_h,
1244                clamped
1245            );
1246        }
1247
1248        // Verify 0.5 maps to 0.5 exactly
1249        let h_half = soft_clamp_hurst(0.5);
1250        assert!(
1251            (h_half - 0.5).abs() < f64::EPSILON,
1252            "Hurst 0.5 should map to 0.5, got {}",
1253            h_half
1254        );
1255    }
1256
1257    // ========== Edge Case Tests ==========
1258
1259    #[test]
1260    fn test_empty_lookback() {
1261        let history = TradeHistory::new(InterBarConfig::default());
1262        let features = history.compute_features(1000);
1263
1264        assert!(
1265            features.lookback_trade_count.is_none() || features.lookback_trade_count == Some(0)
1266        );
1267    }
1268
1269    #[test]
1270    fn test_single_trade_lookback() {
1271        let mut history = TradeHistory::new(InterBarConfig::default());
1272
1273        let trade = AggTrade {
1274            agg_trade_id: 0,
1275            price: FixedPoint(5000000000000),
1276            volume: FixedPoint(100000000),
1277            first_trade_id: 0,
1278            last_trade_id: 0,
1279            timestamp: 0,
1280            is_buyer_maker: false,
1281            is_best_match: None,
1282        };
1283        history.push(&trade);
1284
1285        let features = history.compute_features(1000);
1286
1287        assert_eq!(features.lookback_trade_count, Some(1));
1288        assert_eq!(features.lookback_duration_us, Some(0)); // Single trade = 0 duration
1289    }
1290
1291    #[test]
1292    fn test_kyle_lambda_zero_imbalance() {
1293        // Equal buy/sell -> imbalance = 0 -> should return 0, not infinity
1294        let t0 = create_test_snapshot(0, 100.0, 1.0, false); // buy
1295        let t1 = create_test_snapshot(1000, 102.0, 1.0, true); // sell
1296        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1];
1297
1298        let lambda = compute_kyle_lambda(&lookback);
1299
1300        assert!(
1301            lambda.is_finite(),
1302            "Kyle lambda should be finite, got {}",
1303            lambda
1304        );
1305        assert!(
1306            lambda.abs() < f64::EPSILON,
1307            "Kyle lambda should be 0 for zero imbalance"
1308        );
1309    }
1310
1311    // ========== Tier 2 Features: Comprehensive Edge Cases (Issue #96 Task #43) ==========
1312
1313    #[test]
1314    fn test_kyle_lambda_strong_buy_pressure() {
1315        // Strong buy pressure: many buys, few sells -> positive lambda
1316        let trades: Vec<TradeSnapshot> = (0..5)
1317            .map(|i| create_test_snapshot(i * 1000, 100.0 + i as f64, 1.0, false))
1318            .chain((5..7).map(|i| create_test_snapshot(i * 1000, 100.0 + i as f64, 1.0, true)))
1319            .collect();
1320        let lookback: Vec<&TradeSnapshot> = trades.iter().collect();
1321
1322        let lambda = compute_kyle_lambda(&lookback);
1323        assert!(lambda > 0.0, "Buy pressure should yield positive lambda, got {}", lambda);
1324        assert!(lambda.is_finite(), "Kyle lambda should be finite");
1325    }
1326
1327    #[test]
1328    fn test_kyle_lambda_strong_sell_pressure() {
1329        // Strong sell pressure: many sell orders (is_buyer_maker=true) at declining prices
1330        let t0 = create_test_snapshot(0, 100.0, 1.0, false);    // buy
1331        let t1 = create_test_snapshot(1000, 99.9, 5.0, true);   // sell (larger)
1332        let t2 = create_test_snapshot(2000, 99.8, 5.0, true);   // sell (larger)
1333        let t3 = create_test_snapshot(3000, 99.7, 5.0, true);   // sell (larger)
1334        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3];
1335
1336        let lambda = compute_kyle_lambda(&lookback);
1337        assert!(lambda.is_finite(), "Kyle lambda should be finite");
1338        // With sell volume > buy volume and price declining, lambda should be negative
1339    }
1340
1341    #[test]
1342    fn test_burstiness_single_trade() {
1343        // Single trade: no inter-arrivals, should return default
1344        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
1345        let lookback: Vec<&TradeSnapshot> = vec![&t0];
1346
1347        let b = compute_burstiness(&lookback);
1348        assert!(
1349            b.is_finite(),
1350            "Burstiness with single trade should be finite, got {}",
1351            b
1352        );
1353    }
1354
1355    #[test]
1356    fn test_burstiness_two_trades() {
1357        // Two trades: insufficient data, sigma = 0 -> B = -1
1358        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
1359        let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
1360        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1];
1361
1362        let b = compute_burstiness(&lookback);
1363        assert!(
1364            (b - (-1.0)).abs() < 0.01,
1365            "Burstiness with uniform inter-arrivals should be -1, got {}",
1366            b
1367        );
1368    }
1369
1370    #[test]
1371    fn test_burstiness_bursty_arrivals() {
1372        // Uneven inter-arrivals: clusters of fast then slow arrivals
1373        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
1374        let t1 = create_test_snapshot(100, 100.0, 1.0, false);
1375        let t2 = create_test_snapshot(200, 100.0, 1.0, false);
1376        let t3 = create_test_snapshot(5000, 100.0, 1.0, false);
1377        let t4 = create_test_snapshot(10000, 100.0, 1.0, false);
1378        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3, &t4];
1379
1380        let b = compute_burstiness(&lookback);
1381        assert!(
1382            b > -1.0 && b <= 1.0,
1383            "Burstiness should be bounded [-1, 1], got {}",
1384            b
1385        );
1386    }
1387
1388    #[test]
1389    fn test_volume_skew_right_skewed() {
1390        // Right-skewed distribution (many small, few large volumes)
1391        let t0 = create_test_snapshot(0, 100.0, 0.1, false);
1392        let t1 = create_test_snapshot(1000, 100.0, 0.1, false);
1393        let t2 = create_test_snapshot(2000, 100.0, 0.1, false);
1394        let t3 = create_test_snapshot(3000, 100.0, 0.1, false);
1395        let t4 = create_test_snapshot(4000, 100.0, 10.0, false); // Large outlier
1396        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3, &t4];
1397
1398        let skew = compute_volume_moments(&lookback).0;
1399        assert!(skew > 0.0, "Right-skewed volume should have positive skewness, got {}", skew);
1400        assert!(skew.is_finite(), "Skewness must be finite");
1401    }
1402
1403    #[test]
1404    fn test_volume_kurtosis_heavy_tails() {
1405        // Heavy-tailed distribution (few very large, few very small, middle is sparse)
1406        let t0 = create_test_snapshot(0, 100.0, 0.01, false);
1407        let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
1408        let t2 = create_test_snapshot(2000, 100.0, 1.0, false);
1409        let t3 = create_test_snapshot(3000, 100.0, 1.0, false);
1410        let t4 = create_test_snapshot(4000, 100.0, 100.0, false);
1411        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3, &t4];
1412
1413        let kurtosis = compute_volume_moments(&lookback).1;
1414        assert!(kurtosis > 0.0, "Heavy-tailed distribution should have positive kurtosis, got {}", kurtosis);
1415        assert!(kurtosis.is_finite(), "Kurtosis must be finite");
1416    }
1417
1418    #[test]
1419    fn test_volume_skew_symmetric() {
1420        // Symmetric distribution (equal volumes) -> skewness = 0
1421        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
1422        let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
1423        let t2 = create_test_snapshot(2000, 100.0, 1.0, false);
1424        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2];
1425
1426        let skew = compute_volume_moments(&lookback).0;
1427        assert!(
1428            skew.abs() < f64::EPSILON,
1429            "Symmetric volume distribution should have near-zero skewness, got {}",
1430            skew
1431        );
1432    }
1433
1434    #[test]
1435    fn test_kyle_lambda_price_unchanged() {
1436        // Price doesn't move but there's imbalance -> should be finite
1437        let t0 = create_test_snapshot(0, 100.0, 1.0, false);
1438        let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
1439        let t2 = create_test_snapshot(2000, 100.0, 1.0, false);
1440        let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2];
1441
1442        let lambda = compute_kyle_lambda(&lookback);
1443        assert!(
1444            lambda.is_finite(),
1445            "Kyle lambda should be finite even with no price change, got {}",
1446            lambda
1447        );
1448    }
1449
1450    // ========== BarRelative Mode Tests (Issue #81) ==========
1451
1452    /// Helper to create a test AggTrade
1453    fn make_trade(id: i64, timestamp: i64) -> AggTrade {
1454        AggTrade {
1455            agg_trade_id: id,
1456            price: FixedPoint(5000000000000), // 50000
1457            volume: FixedPoint(100000000),    // 1.0
1458            first_trade_id: id,
1459            last_trade_id: id,
1460            timestamp,
1461            is_buyer_maker: false,
1462            is_best_match: None,
1463        }
1464    }
1465
1466    #[test]
1467    fn test_bar_relative_bootstrap_keeps_all_trades() {
1468        // Before any bars close, BarRelative should keep all trades
1469        let config = InterBarConfig {
1470            lookback_mode: LookbackMode::BarRelative(3),
1471            compute_tier2: false,
1472            compute_tier3: false,
1473        };
1474        let mut history = TradeHistory::new(config);
1475
1476        // Push 100 trades without closing any bar
1477        for i in 0..100 {
1478            history.push(&make_trade(i, i * 1000));
1479        }
1480
1481        assert_eq!(history.len(), 100, "Bootstrap phase should keep all trades");
1482    }
1483
1484    #[test]
1485    fn test_bar_relative_prunes_after_bar_close() {
1486        let config = InterBarConfig {
1487            lookback_mode: LookbackMode::BarRelative(2),
1488            compute_tier2: false,
1489            compute_tier3: false,
1490        };
1491        let mut history = TradeHistory::new(config);
1492
1493        // Bar 1: 10 trades (timestamps 0-9000)
1494        for i in 0..10 {
1495            history.push(&make_trade(i, i * 1000));
1496        }
1497        history.on_bar_close(); // total_pushed = 10
1498
1499        // Bar 2: 20 trades (timestamps 10000-29000)
1500        for i in 10..30 {
1501            history.push(&make_trade(i, i * 1000));
1502        }
1503        history.on_bar_close(); // total_pushed = 30
1504
1505        // Bar 3: 5 trades (timestamps 30000-34000)
1506        for i in 30..35 {
1507            history.push(&make_trade(i, i * 1000));
1508        }
1509        history.on_bar_close(); // total_pushed = 35
1510
1511        // With BarRelative(2), after 3 bar closes we keep trades from last 2 bars:
1512        // bar_close_indices = [10, 30, 35] -> keep last 2 -> from index 10 to 35 = 25 trades
1513        // But bar 1 trades (0-9) should be pruned, keeping bars 2+3 = 25 trades + bar 3's 5
1514        // Actually: bar_close_indices keeps n+1=3 boundaries: [10, 30, 35]
1515        // Oldest boundary at [len-n_bars] = [3-2] = index 1 = 30
1516        // keep_count = total_pushed(35) - 30 = 5
1517        // But wait -- we also have current in-progress trades.
1518        // After bar 3 closes with 35 total, and no more pushes:
1519        // trades.len() should be <= keep_count from the prune in on_bar_close
1520        // The prune happens on each push, and on_bar_close records boundary then
1521        // next push triggers prune.
1522
1523        // Push one more trade to trigger prune with new boundary
1524        history.push(&make_trade(35, 35000));
1525
1526        // Issue #96 Task #155: max_safe_capacity for BarRelative = 2000.
1527        // With only 36 trades total, prune_if_needed() never fires (36 < 2000).
1528        // All trades are preserved — this is correct capacity-based behavior.
1529        assert_eq!(
1530            history.len(),
1531            36,
1532            "All trades preserved below max_safe_capacity (2000), got {}",
1533            history.len()
1534        );
1535    }
1536
1537    #[test]
1538    fn test_bar_relative_mixed_bar_sizes() {
1539        let config = InterBarConfig {
1540            lookback_mode: LookbackMode::BarRelative(2),
1541            compute_tier2: false,
1542            compute_tier3: false,
1543        };
1544        let mut history = TradeHistory::new(config);
1545
1546        // Bar 1: 5 trades
1547        for i in 0..5 {
1548            history.push(&make_trade(i, i * 1000));
1549        }
1550        history.on_bar_close();
1551
1552        // Bar 2: 50 trades
1553        for i in 5..55 {
1554            history.push(&make_trade(i, i * 1000));
1555        }
1556        history.on_bar_close();
1557
1558        // Bar 3: 3 trades
1559        for i in 55..58 {
1560            history.push(&make_trade(i, i * 1000));
1561        }
1562        history.on_bar_close();
1563
1564        // Push one more to trigger prune
1565        history.push(&make_trade(58, 58000));
1566
1567        // Issue #96 Task #155: max_safe_capacity for BarRelative = 2000.
1568        // With only 59 trades total, prune_if_needed() never fires (59 < 2000).
1569        // All trades are preserved — this is correct capacity-based behavior.
1570        assert_eq!(
1571            history.len(),
1572            59,
1573            "All trades preserved below max_safe_capacity (2000), got {}",
1574            history.len()
1575        );
1576    }
1577
1578    #[test]
1579    fn test_bar_relative_lookback_features_computed() {
1580        let config = InterBarConfig {
1581            lookback_mode: LookbackMode::BarRelative(3),
1582            compute_tier2: false,
1583            compute_tier3: false,
1584        };
1585        let mut history = TradeHistory::new(config);
1586
1587        // Push 20 trades (timestamps 0-19000)
1588        for i in 0..20 {
1589            let price = 50000.0 + (i as f64 * 10.0);
1590            let trade = AggTrade {
1591                agg_trade_id: i,
1592                price: FixedPoint((price * 1e8) as i64),
1593                volume: FixedPoint(100000000),
1594                first_trade_id: i,
1595                last_trade_id: i,
1596                timestamp: i * 1000,
1597                is_buyer_maker: i % 2 == 0,
1598                is_best_match: None,
1599            };
1600            history.push(&trade);
1601        }
1602        // Close bar 1 at total_pushed=20
1603        history.on_bar_close();
1604
1605        // Simulate bar 2 opening at timestamp 20000
1606        history.on_bar_open(20000);
1607
1608        // Compute features for bar 2 -- should use trades before 20000
1609        let features = history.compute_features(20000);
1610
1611        // All 20 trades are before bar open, should have lookback features
1612        assert_eq!(features.lookback_trade_count, Some(20));
1613        assert!(features.lookback_ofi.is_some());
1614        assert!(features.lookback_intensity.is_some());
1615    }
1616
1617    #[test]
1618    fn test_bar_relative_reset_bar_boundaries() {
1619        let config = InterBarConfig {
1620            lookback_mode: LookbackMode::BarRelative(2),
1621            compute_tier2: false,
1622            compute_tier3: false,
1623        };
1624        let mut history = TradeHistory::new(config);
1625
1626        // Push trades and close a bar
1627        for i in 0..10 {
1628            history.push(&make_trade(i, i * 1000));
1629        }
1630        history.on_bar_close();
1631
1632        assert_eq!(history.bar_close_indices.len(), 1);
1633
1634        // Reset boundaries (ouroboros)
1635        history.reset_bar_boundaries();
1636
1637        assert!(
1638            history.bar_close_indices.is_empty(),
1639            "bar_close_indices should be empty after reset"
1640        );
1641        // Trades should still be there
1642        assert_eq!(
1643            history.len(),
1644            10,
1645            "Trades should persist after boundary reset"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_bar_relative_on_bar_close_limits_indices() {
1651        let config = InterBarConfig {
1652            lookback_mode: LookbackMode::BarRelative(2),
1653            compute_tier2: false,
1654            compute_tier3: false,
1655        };
1656        let mut history = TradeHistory::new(config);
1657
1658        // Close 5 bars
1659        for bar_num in 0..5 {
1660            for i in 0..5 {
1661                history.push(&make_trade(bar_num * 5 + i, (bar_num * 5 + i) * 1000));
1662            }
1663            history.on_bar_close();
1664        }
1665
1666        // With BarRelative(2), should keep at most n+1=3 boundaries
1667        assert!(
1668            history.bar_close_indices.len() <= 3,
1669            "Should keep at most n+1 boundaries, got {}",
1670            history.bar_close_indices.len()
1671        );
1672    }
1673
1674    #[test]
1675    fn test_bar_relative_does_not_affect_fixed_count() {
1676        // Verify FixedCount mode is unaffected by BarRelative changes
1677        let config = InterBarConfig {
1678            lookback_mode: LookbackMode::FixedCount(10),
1679            compute_tier2: false,
1680            compute_tier3: false,
1681        };
1682        let mut history = TradeHistory::new(config);
1683
1684        for i in 0..30 {
1685            history.push(&make_trade(i, i * 1000));
1686        }
1687        // on_bar_close should be no-op for FixedCount
1688        history.on_bar_close();
1689
1690        // FixedCount(10) keeps 2*10=20 max
1691        assert!(
1692            history.len() <= 20,
1693            "FixedCount(10) should keep at most 20 trades, got {}",
1694            history.len()
1695        );
1696        assert!(
1697            history.bar_close_indices.is_empty(),
1698            "FixedCount should not track bar boundaries"
1699        );
1700    }
1701
1702    // === Memory efficiency tests (R5) ===
1703
1704    #[test]
1705    fn test_volume_moments_numerical_accuracy() {
1706        // R5: Verify 2-pass fold produces identical results to previous 4-pass.
1707        // Symmetric distribution [1,2,3,4,5] → skewness ≈ 0
1708        let price_fp = FixedPoint((100.0 * 1e8) as i64);
1709        let snapshots: Vec<TradeSnapshot> = (1..=5_i64)
1710            .map(|v| {
1711                let volume_fp = FixedPoint((v as f64 * 1e8) as i64);
1712                TradeSnapshot {
1713                    price: price_fp,
1714                    volume: volume_fp,
1715                    timestamp: v * 1000,
1716                    is_buyer_maker: false,
1717                    turnover: price_fp.0 as i128 * volume_fp.0 as i128,
1718                }
1719            })
1720            .collect();
1721        let refs: Vec<&TradeSnapshot> = snapshots.iter().collect();
1722        let (skew, kurt) = compute_volume_moments(&refs);
1723
1724        // Symmetric uniform-like distribution: skewness should be 0
1725        assert!(
1726            skew.abs() < 1e-10,
1727            "Symmetric distribution should have skewness ≈ 0, got {skew}"
1728        );
1729        // Uniform distribution excess kurtosis = -1.3
1730        assert!(
1731            (kurt - (-1.3)).abs() < 0.1,
1732            "Uniform-like kurtosis should be ≈ -1.3, got {kurt}"
1733        );
1734    }
1735
1736    #[test]
1737    fn test_volume_moments_edge_cases() {
1738        let price_fp = FixedPoint((100.0 * 1e8) as i64);
1739
1740        // n < 3 returns (0, 0)
1741        let v1 = FixedPoint((1.0 * 1e8) as i64);
1742        let v2 = FixedPoint((2.0 * 1e8) as i64);
1743        let s1 = TradeSnapshot {
1744            price: price_fp,
1745            volume: v1,
1746            timestamp: 1000,
1747            is_buyer_maker: false,
1748            turnover: price_fp.0 as i128 * v1.0 as i128,
1749        };
1750        let s2 = TradeSnapshot {
1751            price: price_fp,
1752            volume: v2,
1753            timestamp: 2000,
1754            is_buyer_maker: false,
1755            turnover: price_fp.0 as i128 * v2.0 as i128,
1756        };
1757        let refs: Vec<&TradeSnapshot> = vec![&s1, &s2];
1758        let (skew, kurt) = compute_volume_moments(&refs);
1759        assert_eq!(skew, 0.0, "n < 3 should return 0");
1760        assert_eq!(kurt, 0.0, "n < 3 should return 0");
1761
1762        // All same volume returns (0, 0)
1763        let vol = FixedPoint((5.0 * 1e8) as i64);
1764        let same: Vec<TradeSnapshot> = (0..10_i64)
1765            .map(|i| TradeSnapshot {
1766                price: price_fp,
1767                volume: vol,
1768                timestamp: i * 1000,
1769                is_buyer_maker: false,
1770                turnover: price_fp.0 as i128 * vol.0 as i128,
1771            })
1772            .collect();
1773        let refs: Vec<&TradeSnapshot> = same.iter().collect();
1774        let (skew, kurt) = compute_volume_moments(&refs);
1775        assert_eq!(skew, 0.0, "All same volume should return 0");
1776        assert_eq!(kurt, 0.0, "All same volume should return 0");
1777    }
1778
1779    // ========== Optimization Regression Tests (Task #115-119) ==========
1780
1781    #[test]
1782    fn test_optimization_edge_case_zero_trades() {
1783        // Task #115-119: Verify optimizations handle edge case of zero trades gracefully
1784        let history = TradeHistory::new(InterBarConfig::default());
1785
1786        // Try to compute features with no trades
1787        let features = history.compute_features(1000);
1788
1789        // All features should be None for empty lookback
1790        assert!(features.lookback_ofi.is_none());
1791        assert!(features.lookback_kyle_lambda.is_none());
1792        assert!(features.lookback_hurst.is_none());
1793    }
1794
1795    #[test]
1796    fn test_optimization_edge_case_large_lookback() {
1797        // Task #118/119: Verify optimizations handle large lookback windows correctly
1798        // Tests VecDeque capacity optimization and SmallVec trade accumulation
1799        let config = InterBarConfig {
1800            lookback_mode: LookbackMode::FixedCount(500),
1801            ..Default::default()
1802        };
1803        let mut history = TradeHistory::new(config);
1804
1805        // Add 600 trades (exceeds 500-trade lookback)
1806        for i in 0..600_i64 {
1807            let snapshot = create_test_snapshot(i * 1000, 100.0, 10.0, i % 2 == 0);
1808            history.push(&AggTrade {
1809                agg_trade_id: i,
1810                price: snapshot.price,
1811                volume: snapshot.volume,
1812                first_trade_id: i,
1813                last_trade_id: i,
1814                timestamp: snapshot.timestamp,
1815                is_buyer_maker: snapshot.is_buyer_maker,
1816                is_best_match: Some(false),
1817            });
1818        }
1819
1820        // Verify that pruning maintains correct lookback window
1821        let lookback = history.get_lookback_trades(599000);
1822        assert!(
1823            lookback.len() <= 600, // Should be around 500, maybe a bit more
1824            "Lookback should be <= 600 trades, got {}", lookback.len()
1825        );
1826
1827        // Compute features - this exercises the optimizations
1828        let features = history.compute_features(599000);
1829
1830        // Tier 1 features should be present
1831        assert!(features.lookback_trade_count.is_some(), "Trade count should be computed");
1832        assert!(features.lookback_ofi.is_some(), "OFI should be computed");
1833    }
1834
1835    #[test]
1836    fn test_optimization_edge_case_single_trade() {
1837        // Task #115-119: Verify optimizations handle single-trade edge case
1838        let mut history = TradeHistory::new(InterBarConfig::default());
1839
1840        let snapshot = create_test_snapshot(1000, 100.0, 10.0, false);
1841        history.push(&AggTrade {
1842            agg_trade_id: 1,
1843            price: snapshot.price,
1844            volume: snapshot.volume,
1845            first_trade_id: 1,
1846            last_trade_id: 1,
1847            timestamp: snapshot.timestamp,
1848            is_buyer_maker: snapshot.is_buyer_maker,
1849            is_best_match: Some(false),
1850        });
1851
1852        let features = history.compute_features(2000);
1853
1854        // Tier 1 should compute (only 1 trade needed)
1855        assert!(features.lookback_trade_count.is_some());
1856        // Tier 3 definitely not (needs >= 60 for Hurst/Entropy)
1857        assert!(features.lookback_hurst.is_none());
1858    }
1859
1860    #[test]
1861    fn test_optimization_many_trades() {
1862        // Task #119: Verify SmallVec handles typical bar trade counts (100-500)
1863        let mut history = TradeHistory::new(InterBarConfig::default());
1864
1865        // Add 300 trades
1866        for i in 0..300_i64 {
1867            let snapshot = create_test_snapshot(
1868                i * 1000,
1869                100.0 + (i as f64 % 10.0),
1870                10.0 + (i as f64 % 5.0),
1871                i % 2 == 0,
1872            );
1873            history.push(&AggTrade {
1874                agg_trade_id: i,
1875                price: snapshot.price,
1876                volume: snapshot.volume,
1877                first_trade_id: i,
1878                last_trade_id: i,
1879                timestamp: snapshot.timestamp,
1880                is_buyer_maker: snapshot.is_buyer_maker,
1881                is_best_match: Some(false),
1882            });
1883        }
1884
1885        // Get lookback trades
1886        let lookback = history.get_lookback_trades(299000);
1887
1888        // Compute features with both tiers enabled (Task #115: rayon parallelization)
1889        let features = history.compute_features(299000);
1890
1891        // Verify Tier 2 features are present
1892        assert!(features.lookback_kyle_lambda.is_some(), "Kyle lambda should be computed");
1893        assert!(features.lookback_burstiness.is_some(), "Burstiness should be computed");
1894
1895        // Verify Tier 3 features are present (only if n >= 60)
1896        if lookback.len() >= 60 {
1897            assert!(features.lookback_hurst.is_some(), "Hurst should be computed");
1898            assert!(features.lookback_permutation_entropy.is_some(), "Entropy should be computed");
1899        }
1900    }
1901
1902    #[test]
1903    fn test_trade_history_with_external_cache() {
1904        // Issue #145 Phase 2: Test that TradeHistory accepts optional external cache
1905        use crate::entropy_cache_global::get_global_entropy_cache;
1906
1907        // Test 1: Local cache (backward compatible)
1908        let _local_history = TradeHistory::new(InterBarConfig::default());
1909        // Should work without issues - backward compatible
1910
1911        // Test 2: External global cache
1912        let global_cache = get_global_entropy_cache();
1913        let _shared_history = TradeHistory::new_with_cache(InterBarConfig::default(), Some(global_cache.clone()));
1914        // Should work without issues - uses provided cache
1915
1916        // Both constructors work correctly and can be created without panicking
1917    }
1918
1919    #[test]
1920    fn test_feature_result_cache_hit_miss() {
1921        // Issue #96 Task #144 Phase 4: Verify cache hit/miss behavior
1922        use crate::types::AggTrade;
1923
1924        fn create_test_trade(price: f64, volume: f64, is_buyer_maker: bool) -> AggTrade {
1925            AggTrade {
1926                agg_trade_id: 1,
1927                timestamp: 1000000,
1928                price: FixedPoint((price * 1e8) as i64),
1929                volume: FixedPoint((volume * 1e8) as i64),
1930                first_trade_id: 1,
1931                last_trade_id: 1,
1932                is_buyer_maker,
1933                is_best_match: Some(true),
1934            }
1935        }
1936
1937        // Create trade history with Tier 1 only for speed
1938        let mut history = TradeHistory::new(InterBarConfig {
1939            lookback_mode: LookbackMode::FixedCount(50),
1940            compute_tier2: false,
1941            compute_tier3: false,
1942        });
1943
1944        // Create test trades
1945        let trades = vec![
1946            create_test_trade(100.0, 1.0, false),
1947            create_test_trade(100.5, 1.5, true),
1948            create_test_trade(100.2, 1.2, false),
1949        ];
1950
1951        for trade in &trades {
1952            history.push(trade);
1953        }
1954
1955        // First call: cache miss (computes features and stores in cache)
1956        let features1 = history.compute_features(2000000);
1957        assert!(features1.lookback_trade_count == Some(3));
1958
1959        // Second call: cache hit (retrieves from cache)
1960        let features2 = history.compute_features(2000000);
1961        assert!(features2.lookback_trade_count == Some(3));
1962
1963        // Both should produce identical results
1964        assert_eq!(features1.lookback_ofi, features2.lookback_ofi);
1965        assert_eq!(features1.lookback_count_imbalance, features2.lookback_count_imbalance);
1966    }
1967
1968    #[test]
1969    fn test_feature_result_cache_multiple_computations() {
1970        // Issue #96 Task #144 Phase 4: Verify cache works across multiple computations
1971        use crate::types::AggTrade;
1972
1973        fn create_test_trade(price: f64, volume: f64, timestamp: i64, is_buyer_maker: bool) -> AggTrade {
1974            AggTrade {
1975                agg_trade_id: 1,
1976                timestamp,
1977                price: FixedPoint((price * 1e8) as i64),
1978                volume: FixedPoint((volume * 1e8) as i64),
1979                first_trade_id: 1,
1980                last_trade_id: 1,
1981                is_buyer_maker,
1982                is_best_match: Some(true),
1983            }
1984        }
1985
1986        let mut history = TradeHistory::new(InterBarConfig {
1987            lookback_mode: LookbackMode::FixedCount(50),
1988            compute_tier2: false,
1989            compute_tier3: false,
1990        });
1991
1992        // Create trades with specific timestamps
1993        let trades = vec![
1994            create_test_trade(100.0, 1.0, 1000000, false),
1995            create_test_trade(100.5, 1.5, 2000000, true),
1996            create_test_trade(100.2, 1.2, 3000000, false),
1997            create_test_trade(100.1, 1.1, 4000000, true),
1998        ];
1999
2000        for trade in &trades {
2001            history.push(trade);
2002        }
2003
2004        // First computation - cache miss
2005        let features1 = history.compute_features(5000000); // Bar open after all trades
2006        assert_eq!(features1.lookback_trade_count, Some(4));
2007        let ofi1 = features1.lookback_ofi;
2008
2009        // Second computation with same bar_open_time - cache hit
2010        let features2 = history.compute_features(5000000);
2011        assert_eq!(features2.lookback_trade_count, Some(4));
2012        assert_eq!(features2.lookback_ofi, ofi1, "Cache hit should return identical OFI");
2013
2014        // Third computation - different bar_open_time, different window
2015        let features3 = history.compute_features(3500000); // Gets trades before 3.5M (3 trades)
2016        assert_eq!(features3.lookback_trade_count, Some(3));
2017
2018        // Fourth computation - same as first, should reuse cache
2019        let features4 = history.compute_features(5000000);
2020        assert_eq!(features4.lookback_ofi, ofi1, "Cache reuse should return identical results");
2021    }
2022
2023    #[test]
2024    fn test_feature_result_cache_different_windows() {
2025        // Issue #96 Task #144 Phase 4: Verify cache distinguishes different windows
2026        use crate::types::AggTrade;
2027
2028        fn create_test_trade(price: f64, volume: f64, timestamp: i64, is_buyer_maker: bool) -> AggTrade {
2029            AggTrade {
2030                agg_trade_id: 1,
2031                timestamp,
2032                price: FixedPoint((price * 1e8) as i64),
2033                volume: FixedPoint((volume * 1e8) as i64),
2034                first_trade_id: 1,
2035                last_trade_id: 1,
2036                is_buyer_maker,
2037                is_best_match: Some(true),
2038            }
2039        }
2040
2041        let mut history = TradeHistory::new(InterBarConfig {
2042            lookback_mode: LookbackMode::FixedCount(100),
2043            compute_tier2: false,
2044            compute_tier3: false,
2045        });
2046
2047        // Add 10 trades with sequential timestamps
2048        for i in 0..10 {
2049            let trade = create_test_trade(
2050                100.0 + (i as f64 * 0.1),
2051                1.0 + (i as f64 * 0.01),
2052                1000000 + (i as i64 * 100000), // Timestamps: 1M, 1.1M, 1.2M, ..., 1.9M
2053                i % 2 == 0,
2054            );
2055            history.push(&trade);
2056        }
2057
2058        // Compute features at bar_open_time=2M (gets all 10 trades, all have ts < 2M)
2059        let features1 = history.compute_features(2000000);
2060        assert_eq!(features1.lookback_trade_count, Some(10));
2061
2062        // Add more trades beyond the bar_open_time cutoff (timestamps >= 2M)
2063        for i in 10..15 {
2064            let trade = create_test_trade(
2065                100.0 + (i as f64 * 0.1),
2066                1.0 + (i as f64 * 0.01),
2067                2000000 + (i as i64 * 100000), // Timestamps: 2M, 2.1M, ..., 2.4M (after bar_open_time)
2068                i % 2 == 0,
2069            );
2070            history.push(&trade);
2071        }
2072
2073        // Compute features at same bar_open_time=2M - should still get only 10 trades (same lookback cutoff)
2074        let features2 = history.compute_features(2000000);
2075        assert_eq!(features2.lookback_trade_count, Some(10));
2076
2077        // Results should be identical (same window)
2078        assert_eq!(features1.lookback_ofi, features2.lookback_ofi);
2079    }
2080
2081    #[test]
2082    fn test_adaptive_pruning_batch_size_tracked() {
2083        // Issue #96 Task #155: Verify adaptive pruning batch size is tracked
2084        use crate::types::AggTrade;
2085
2086        fn create_test_trade(price: f64, timestamp: i64) -> AggTrade {
2087            AggTrade {
2088                agg_trade_id: 1,
2089                timestamp,
2090                price: FixedPoint((price * 1e8) as i64),
2091                volume: FixedPoint((1.0 * 1e8) as i64),
2092                first_trade_id: 1,
2093                last_trade_id: 1,
2094                is_buyer_maker: false,
2095                is_best_match: Some(true),
2096            }
2097        }
2098
2099        let mut history = TradeHistory::new(InterBarConfig {
2100            lookback_mode: LookbackMode::FixedCount(100),
2101            compute_tier2: false,
2102            compute_tier3: false,
2103        });
2104
2105        let initial_batch = history.adaptive_prune_batch;
2106        assert!(initial_batch > 0, "Initial batch size should be positive");
2107
2108        // Add trades and verify batch size remains reasonable
2109        for i in 0..100 {
2110            let trade = create_test_trade(
2111                100.0 + (i as f64 * 0.01),
2112                1_000_000 + (i as i64 * 100),
2113            );
2114            history.push(&trade);
2115        }
2116
2117        // Batch size should be reasonable (not zero, not excessively large)
2118        assert!(
2119            history.adaptive_prune_batch > 0 && history.adaptive_prune_batch <= initial_batch * 4,
2120            "Batch size should be reasonable"
2121        );
2122    }
2123
2124    #[test]
2125    fn test_adaptive_pruning_deferred() {
2126        // Issue #96 Task #155: Verify deferred pruning respects capacity bounds
2127        use crate::types::AggTrade;
2128
2129        fn create_test_trade(price: f64, timestamp: i64) -> AggTrade {
2130            AggTrade {
2131                agg_trade_id: 1,
2132                timestamp,
2133                price: FixedPoint((price * 1e8) as i64),
2134                volume: FixedPoint((1.0 * 1e8) as i64),
2135                first_trade_id: 1,
2136                last_trade_id: 1,
2137                is_buyer_maker: false,
2138                is_best_match: Some(true),
2139            }
2140        }
2141
2142        let mut history = TradeHistory::new(InterBarConfig {
2143            lookback_mode: LookbackMode::FixedCount(50),
2144            compute_tier2: false,
2145            compute_tier3: false,
2146        });
2147
2148        let max_capacity = history.max_safe_capacity;
2149
2150        // Add 300 trades - should trigger deferred pruning when hitting 2x capacity
2151        for i in 0..300 {
2152            let trade = create_test_trade(
2153                100.0 + (i as f64 * 0.01),
2154                1_000_000 + (i as i64 * 100),
2155            );
2156            history.push(&trade);
2157        }
2158
2159        // After adding trades, trade count should be reasonable
2160        // (deferred pruning activates when > max_capacity * 2)
2161        assert!(
2162            history.trades.len() <= max_capacity * 3,
2163            "Trade count should be controlled by deferred pruning"
2164        );
2165    }
2166
2167    #[test]
2168    fn test_adaptive_pruning_stats_tracking() {
2169        // Issue #96 Task #155: Verify pruning statistics are tracked correctly
2170        use crate::types::AggTrade;
2171
2172        fn create_test_trade(price: f64, timestamp: i64) -> AggTrade {
2173            AggTrade {
2174                agg_trade_id: 1,
2175                timestamp,
2176                price: FixedPoint((price * 1e8) as i64),
2177                volume: FixedPoint((1.0 * 1e8) as i64),
2178                first_trade_id: 1,
2179                last_trade_id: 1,
2180                is_buyer_maker: false,
2181                is_best_match: Some(true),
2182            }
2183        }
2184
2185        let mut history = TradeHistory::new(InterBarConfig {
2186            lookback_mode: LookbackMode::FixedCount(100),
2187            compute_tier2: false,
2188            compute_tier3: false,
2189        });
2190
2191        // Initial stats should be empty
2192        assert_eq!(history.prune_stats, (0, 0), "Initial stats should be zero");
2193
2194        // Add enough trades to trigger pruning (exceed 2x capacity)
2195        for i in 0..2000 {
2196            let trade = create_test_trade(
2197                100.0 + (i as f64 * 0.01),
2198                1_000_000 + (i as i64 * 100),
2199            );
2200            history.push(&trade);
2201        }
2202
2203        // Stats should have been updated after pruning
2204        // Note: Stats are reset every 10 prune calls, so they might be (0,0) if exactly 10 calls happened
2205        // Just verify structure is there and reasonable
2206        assert!(
2207            history.prune_stats.0 <= 2000 && history.prune_stats.1 <= 10,
2208            "Pruning stats should be reasonable"
2209        );
2210    }
2211
2212    // === EDGE CASE TESTS (Issue #96 Task #22) ===
2213
2214    fn make_agg_trade(id: i64, price: f64, timestamp: i64) -> AggTrade {
2215        AggTrade {
2216            agg_trade_id: id,
2217            price: FixedPoint((price * 1e8) as i64),
2218            volume: FixedPoint(100000000), // 1.0
2219            first_trade_id: id,
2220            last_trade_id: id,
2221            timestamp,
2222            is_buyer_maker: false,
2223            is_best_match: None,
2224        }
2225    }
2226
2227    #[test]
2228    fn test_get_lookback_empty_history() {
2229        let history = TradeHistory::new(InterBarConfig::default());
2230        let lookback = history.get_lookback_trades(1000);
2231        assert!(lookback.is_empty(), "Empty history should return empty lookback");
2232    }
2233
2234    #[test]
2235    fn test_has_lookback_empty_history() {
2236        let history = TradeHistory::new(InterBarConfig::default());
2237        assert!(!history.has_lookback_trades(1000), "Empty history should have no lookback");
2238    }
2239
2240    #[test]
2241    fn test_get_lookback_all_trades_after_bar_open() {
2242        let mut history = TradeHistory::new(InterBarConfig::default());
2243        for i in 0..5 {
2244            history.push(&make_agg_trade(i, 100.0, 2000 + i));
2245        }
2246        let lookback = history.get_lookback_trades(1000);
2247        assert!(lookback.is_empty(), "All trades after bar_open_time should yield empty lookback");
2248    }
2249
2250    #[test]
2251    fn test_compute_features_minimum_lookback() {
2252        let mut history = TradeHistory::new(InterBarConfig::default());
2253        history.push(&make_agg_trade(1, 100.0, 1000));
2254        history.push(&make_agg_trade(2, 101.0, 2000));
2255
2256        let features = history.compute_features(3000);
2257        assert!(features.lookback_ofi.is_some(), "OFI should compute with 2 trades");
2258        assert_eq!(features.lookback_trade_count, Some(2));
2259    }
2260
2261    #[test]
2262    fn test_has_lookback_cache_hit_path() {
2263        let mut history = TradeHistory::new(InterBarConfig::default());
2264        for i in 0..10 {
2265            history.push(&make_agg_trade(i, 100.0, i * 100));
2266        }
2267        let has1 = history.has_lookback_trades(500);
2268        let has2 = history.has_lookback_trades(500);
2269        assert_eq!(has1, has2, "Cache hit should return same result");
2270        assert!(has1, "Should have lookback trades before ts=500");
2271    }
2272
2273    #[test]
2274    fn test_get_lookback_trades_at_exact_timestamp() {
2275        let mut history = TradeHistory::new(InterBarConfig::default());
2276        for i in 1..=3i64 {
2277            history.push(&make_agg_trade(i, 100.0, i * 100));
2278        }
2279        // bar_open_time = 200: should get trades BEFORE 200 (only ts=100)
2280        let lookback = history.get_lookback_trades(200);
2281        assert_eq!(lookback.len(), 1, "Should get 1 trade before ts=200");
2282        assert_eq!(lookback[0].timestamp, 100);
2283    }
2284
2285    // === buffer_stats() and has_lookback_trades() edge case tests (Issue #96 Task #71) ===
2286
2287    #[test]
2288    fn test_buffer_stats_empty_history() {
2289        let history = TradeHistory::new(InterBarConfig::default());
2290        let (trades_len, max_capacity, _batch, trades_pruned) = history.buffer_stats();
2291        assert_eq!(trades_len, 0, "Empty history should have 0 trades");
2292        assert!(max_capacity > 0, "max_safe_capacity should be positive");
2293        assert_eq!(trades_pruned, 0, "No trades should have been pruned");
2294    }
2295
2296    #[test]
2297    fn test_buffer_stats_after_pushes() {
2298        let mut history = TradeHistory::new(InterBarConfig::default());
2299        for i in 0..5 {
2300            history.push(&make_agg_trade(i, 100.0, i * 100));
2301        }
2302        let (trades_len, _max_capacity, _batch, _trades_pruned) = history.buffer_stats();
2303        assert_eq!(trades_len, 5, "Should have 5 trades after 5 pushes");
2304    }
2305
2306    #[test]
2307    fn test_has_lookback_no_trades_before_open() {
2308        let mut history = TradeHistory::new(InterBarConfig::default());
2309        // All trades at timestamp 1000+
2310        for i in 0..5 {
2311            history.push(&make_agg_trade(i, 100.0, 1000 + i * 100));
2312        }
2313        // bar_open_time before all trades: should have lookback
2314        assert!(history.has_lookback_trades(1000 + 200), "Should have lookback before ts=1200");
2315        // bar_open_time at first trade: no trades BEFORE it
2316        assert!(!history.has_lookback_trades(1000), "No trades before first trade timestamp");
2317        // bar_open_time before all trades: no lookback
2318        assert!(!history.has_lookback_trades(500), "No trades before ts=500");
2319    }
2320
2321    #[test]
2322    fn test_has_lookback_all_trades_before_open() {
2323        let mut history = TradeHistory::new(InterBarConfig::default());
2324        for i in 0..5 {
2325            history.push(&make_agg_trade(i, 100.0, i * 100));
2326        }
2327        // bar_open_time after all trades: all 5 trades are lookback
2328        assert!(history.has_lookback_trades(999), "All trades should be lookback");
2329    }
2330
2331    #[test]
2332    fn test_buffer_stats_len_matches_is_empty() {
2333        let history = TradeHistory::new(InterBarConfig::default());
2334        assert!(history.is_empty(), "New history should be empty");
2335        assert_eq!(history.len(), 0, "New history length should be 0");
2336
2337        let mut history2 = TradeHistory::new(InterBarConfig::default());
2338        history2.push(&make_agg_trade(1, 100.0, 1000));
2339        assert!(!history2.is_empty(), "History with 1 trade should not be empty");
2340        assert_eq!(history2.len(), 1, "History length should be 1");
2341    }
2342
2343    // Issue #96 Task #94: Integration test for Tier 2/3 dispatch paths
2344    // All prior tests use compute_tier2: false, compute_tier3: false.
2345    // This exercises the 4-branch parallelization dispatch (lines 656-695).
2346
2347    #[test]
2348    fn test_tier2_features_computed_when_enabled() {
2349        let config = InterBarConfig {
2350            lookback_mode: LookbackMode::FixedCount(500),
2351            compute_tier2: true,
2352            compute_tier3: false,
2353        };
2354        let mut history = TradeHistory::new(config);
2355
2356        // Push 120 trades with realistic price variation and mixed buy/sell
2357        for i in 0..120i64 {
2358            let price = 50000.0 + (i as f64 * 0.7).sin() * 50.0;
2359            let volume = 1.0 + (i % 5) as f64 * 0.5;
2360            let trade = AggTrade {
2361                agg_trade_id: i,
2362                price: FixedPoint((price * 1e8) as i64),
2363                volume: FixedPoint((volume * 1e8) as i64),
2364                first_trade_id: i,
2365                last_trade_id: i,
2366                timestamp: i * 500, // 500us apart
2367                is_buyer_maker: i % 3 == 0, // ~33% sellers
2368                is_best_match: None,
2369            };
2370            history.push(&trade);
2371        }
2372
2373        let features = history.compute_features(120 * 500);
2374
2375        // Tier 1 should always be present
2376        assert!(features.lookback_trade_count.is_some(), "trade_count should be Some");
2377        assert!(features.lookback_ofi.is_some(), "ofi should be Some");
2378
2379        // Tier 2 features should be computed
2380        assert!(features.lookback_kyle_lambda.is_some(), "kyle_lambda should be Some with tier2 enabled");
2381        assert!(features.lookback_burstiness.is_some(), "burstiness should be Some with tier2 enabled");
2382        assert!(features.lookback_volume_skew.is_some(), "volume_skew should be Some with tier2 enabled");
2383        assert!(features.lookback_volume_kurt.is_some(), "volume_kurt should be Some with tier2 enabled");
2384        assert!(features.lookback_price_range.is_some(), "price_range should be Some with tier2 enabled");
2385
2386        // Tier 3 features should remain None
2387        assert!(features.lookback_kaufman_er.is_none(), "kaufman_er should be None with tier3 disabled");
2388        assert!(features.lookback_hurst.is_none(), "hurst should be None with tier3 disabled");
2389    }
2390
2391    #[test]
2392    fn test_tier3_features_computed_when_enabled() {
2393        let config = InterBarConfig {
2394            lookback_mode: LookbackMode::FixedCount(500),
2395            compute_tier2: false,
2396            compute_tier3: true,
2397        };
2398        let mut history = TradeHistory::new(config);
2399
2400        // Push 120 trades (>64 for Hurst, >60 for PE)
2401        for i in 0..120i64 {
2402            let price = 50000.0 + (i as f64 * 0.7).sin() * 50.0;
2403            let trade = AggTrade {
2404                agg_trade_id: i,
2405                price: FixedPoint((price * 1e8) as i64),
2406                volume: FixedPoint((1.5 * 1e8) as i64),
2407                first_trade_id: i,
2408                last_trade_id: i,
2409                timestamp: i * 500,
2410                is_buyer_maker: i % 2 == 0,
2411                is_best_match: None,
2412            };
2413            history.push(&trade);
2414        }
2415
2416        let features = history.compute_features(120 * 500);
2417
2418        // Tier 1 should be present
2419        assert!(features.lookback_trade_count.is_some(), "trade_count should be Some");
2420
2421        // Tier 2 should remain None
2422        assert!(features.lookback_kyle_lambda.is_none(), "kyle_lambda should be None with tier2 disabled");
2423        assert!(features.lookback_burstiness.is_none(), "burstiness should be None with tier2 disabled");
2424
2425        // Tier 3 features should be computed (120 trades > 64 for Hurst, > 60 for PE)
2426        assert!(features.lookback_kaufman_er.is_some(), "kaufman_er should be Some with tier3 enabled");
2427        assert!(features.lookback_garman_klass_vol.is_some(), "garman_klass should be Some with tier3 enabled");
2428    }
2429
2430    #[test]
2431    fn test_all_tiers_enabled_parallel_dispatch() {
2432        let config = InterBarConfig {
2433            lookback_mode: LookbackMode::FixedCount(500),
2434            compute_tier2: true,
2435            compute_tier3: true,
2436        };
2437        let mut history = TradeHistory::new(config);
2438
2439        // Push 200 trades to exceed parallel thresholds (80 for Tier2, 150 for Tier3)
2440        for i in 0..200i64 {
2441            let price = 50000.0 + (i as f64 * 0.3).sin() * 100.0;
2442            let volume = 0.5 + (i % 7) as f64 * 0.3;
2443            let trade = AggTrade {
2444                agg_trade_id: i,
2445                price: FixedPoint((price * 1e8) as i64),
2446                volume: FixedPoint((volume * 1e8) as i64),
2447                first_trade_id: i,
2448                last_trade_id: i + 2, // aggregation_density > 1
2449                timestamp: i * 1000,
2450                is_buyer_maker: i % 4 == 0, // ~25% sellers
2451                is_best_match: None,
2452            };
2453            history.push(&trade);
2454        }
2455
2456        let features = history.compute_features(200 * 1000);
2457
2458        // All tiers should be computed
2459        assert!(features.lookback_trade_count.is_some(), "trade_count");
2460        assert!(features.lookback_ofi.is_some(), "ofi");
2461        assert!(features.lookback_intensity.is_some(), "intensity");
2462        assert!(features.lookback_vwap.is_some(), "vwap");
2463
2464        // Tier 2
2465        assert!(features.lookback_kyle_lambda.is_some(), "kyle_lambda");
2466        assert!(features.lookback_burstiness.is_some(), "burstiness");
2467        assert!(features.lookback_volume_skew.is_some(), "volume_skew");
2468        assert!(features.lookback_volume_kurt.is_some(), "volume_kurt");
2469        assert!(features.lookback_price_range.is_some(), "price_range");
2470
2471        // Tier 3
2472        assert!(features.lookback_kaufman_er.is_some(), "kaufman_er");
2473        assert!(features.lookback_garman_klass_vol.is_some(), "garman_klass_vol");
2474
2475        // Verify feature values are finite
2476        assert!(features.lookback_ofi.unwrap().is_finite(), "ofi should be finite");
2477        assert!(features.lookback_kyle_lambda.unwrap().is_finite(), "kyle_lambda should be finite");
2478        assert!(features.lookback_kaufman_er.unwrap().is_finite(), "kaufman_er should be finite");
2479    }
2480}