Skip to main content

opendeviationbar_core/intrabar/
features.rs

1//! Intra-bar feature computation from constituent trades.
2//!
3//! Issue #59: Intra-bar microstructure features for large open deviation bars.
4//!
5//! This module computes 22 features from trades WITHIN each open deviation bar:
6//! - 8 ITH features (from trading-fitness algorithms)
7//! - 12 statistical features
8//! - 2 complexity features (Hurst, Permutation Entropy)
9//!
10//! # FILE-SIZE-OK
11//! 942 lines: Large existing file with multiple feature computation functions.
12//! Keeping together maintains performance optimization context.
13
14use crate::types::AggTrade;
15use smallvec::SmallVec;
16
17use super::drawdown::compute_max_drawdown_and_runup;
18use super::ith::{bear_ith, bull_ith};
19use super::normalize::{
20    normalize_cv, normalize_drawdown, normalize_epochs, normalize_excess, normalize_runup,
21};
22use super::normalization_lut::soft_clamp_hurst_lut;
23
24/// Issue #128: Configuration for intra-bar feature computation.
25///
26/// Controls which expensive complexity features (Hurst, Permutation Entropy)
27/// are computed within each bar. ITH and statistical features are always computed.
28#[derive(Debug, Clone)]
29pub struct IntraBarConfig {
30    /// Whether to compute intra-bar Hurst exponent (requires >= 64 trades)
31    pub compute_hurst: bool,
32    /// Whether to compute intra-bar Permutation Entropy (requires >= 60 trades)
33    pub compute_permutation_entropy: bool,
34}
35
36impl Default for IntraBarConfig {
37    fn default() -> Self {
38        Self {
39            compute_hurst: true,
40            compute_permutation_entropy: true,
41        }
42    }
43}
44
45/// Pre-computed ln(3!) = ln(6) for permutation entropy normalization (m=3, Bandt-Pompe).
46/// Avoids per-bar ln() call. Task #9.
47const MAX_ENTROPY_M3: f64 = 1.791_759_469_228_327;
48
49/// All 22 intra-bar features computed from constituent trades.
50///
51/// All ITH-based features are normalized to [0, 1] for LSTM consumption.
52/// Statistical features preserve their natural ranges.
53/// Optional fields return None when insufficient data.
54#[derive(Debug, Clone, Default)]
55pub struct IntraBarFeatures {
56    // === ITH Features (8) - All bounded [0, 1] ===
57    /// Bull epoch density: sigmoid(epochs/trade_count, 0.5, 10)
58    pub intra_bull_epoch_density: Option<f64>,
59    /// Bear epoch density: sigmoid(epochs/trade_count, 0.5, 10)
60    pub intra_bear_epoch_density: Option<f64>,
61    /// Bull excess gain (sum): tanh-normalized to [0, 1]
62    pub intra_bull_excess_gain: Option<f64>,
63    /// Bear excess gain (sum): tanh-normalized to [0, 1]
64    pub intra_bear_excess_gain: Option<f64>,
65    /// Bull intervals CV: sigmoid-normalized to [0, 1]
66    pub intra_bull_cv: Option<f64>,
67    /// Bear intervals CV: sigmoid-normalized to [0, 1]
68    pub intra_bear_cv: Option<f64>,
69    /// Max drawdown in bar: already [0, 1]
70    pub intra_max_drawdown: Option<f64>,
71    /// Max runup in bar: already [0, 1]
72    pub intra_max_runup: Option<f64>,
73
74    // === Statistical Features (12) ===
75    /// Number of trades in the bar
76    pub intra_trade_count: Option<u32>,
77    /// Order Flow Imbalance: (buy_vol - sell_vol) / total_vol, [-1, 1]
78    pub intra_ofi: Option<f64>,
79    /// Duration of bar in microseconds
80    pub intra_duration_us: Option<i64>,
81    /// Trade intensity: trades per second
82    pub intra_intensity: Option<f64>,
83    /// VWAP position within price range: [0, 1]
84    pub intra_vwap_position: Option<f64>,
85    /// Count imbalance: (buy_count - sell_count) / total_count, [-1, 1]
86    pub intra_count_imbalance: Option<f64>,
87    /// Kyle's Lambda proxy (normalized)
88    pub intra_kyle_lambda: Option<f64>,
89    /// Burstiness (Goh-Barabási): [-1, 1]
90    pub intra_burstiness: Option<f64>,
91    /// Volume skewness
92    pub intra_volume_skew: Option<f64>,
93    /// Volume excess kurtosis
94    pub intra_volume_kurt: Option<f64>,
95    /// Kaufman Efficiency Ratio: [0, 1]
96    pub intra_kaufman_er: Option<f64>,
97    /// Garman-Klass volatility estimator
98    pub intra_garman_klass_vol: Option<f64>,
99
100    // === Complexity Features (2) - Require many trades ===
101    /// Hurst exponent via DFA (requires >= 64 trades)
102    pub intra_hurst: Option<f64>,
103    /// Permutation entropy (requires >= 60 trades)
104    pub intra_permutation_entropy: Option<f64>,
105}
106
107/// Cold path: return features for zero-trade bar
108/// Extracted to improve instruction cache locality on the hot path
109#[cold]
110#[inline(never)]
111fn intra_bar_zero_trades() -> IntraBarFeatures {
112    IntraBarFeatures {
113        intra_trade_count: Some(0),
114        ..Default::default()
115    }
116}
117
118/// Cold path: return features for single-trade bar
119#[cold]
120#[inline(never)]
121fn intra_bar_single_trade() -> IntraBarFeatures {
122    IntraBarFeatures {
123        intra_trade_count: Some(1),
124        intra_duration_us: Some(0),
125        intra_intensity: Some(0.0),
126        intra_ofi: Some(0.0),
127        ..Default::default()
128    }
129}
130
131/// Cold path: return features for bar with invalid first price
132#[cold]
133#[inline(never)]
134fn intra_bar_invalid_price(n: usize) -> IntraBarFeatures {
135    IntraBarFeatures {
136        intra_trade_count: Some(n as u32),
137        ..Default::default()
138    }
139}
140
141/// Compute all intra-bar features from constituent trades.
142///
143/// This is the main entry point for computing ITH and statistical features
144/// from the trades that formed a open deviation bar.
145///
146/// # Arguments
147/// * `trades` - Slice of AggTrade records within the bar
148///
149/// # Returns
150/// `IntraBarFeatures` struct with all 22 features (or None for insufficient data)
151///
152/// Issue #96 Task #173: Uses reusable scratch buffers if available for zero-copy extraction
153/// Issue #96 Task #52: #[inline] for delegation to _with_scratch
154#[inline]
155pub fn compute_intra_bar_features(trades: &[AggTrade]) -> IntraBarFeatures {
156    let mut scratch_prices = SmallVec::<[f64; 64]>::new();
157    let mut scratch_volumes = SmallVec::<[f64; 64]>::new();
158    compute_intra_bar_features_with_scratch(trades, &mut scratch_prices, &mut scratch_volumes)
159}
160
161/// Optimized version accepting reusable scratch buffers
162/// Issue #96 Task #173: Avoids per-bar heap allocation by reusing buffers across bars
163/// Issue #96 Task #88: #[inline] — per-bar dispatcher called from processor hot path
164/// Issue #128: Delegates to config-aware version with default config (all features on)
165#[inline]
166pub fn compute_intra_bar_features_with_scratch(
167    trades: &[AggTrade],
168    scratch_prices: &mut SmallVec<[f64; 64]>,
169    scratch_volumes: &mut SmallVec<[f64; 64]>,
170) -> IntraBarFeatures {
171    compute_intra_bar_features_with_config(trades, scratch_prices, scratch_volumes, &IntraBarConfig::default())
172}
173
174/// Issue #128: Config-aware version that gates expensive complexity features.
175/// Issue #96 Task #88: #[inline] — per-bar dispatcher called from processor hot path
176#[inline]
177pub fn compute_intra_bar_features_with_config(
178    trades: &[AggTrade],
179    scratch_prices: &mut SmallVec<[f64; 64]>,
180    scratch_volumes: &mut SmallVec<[f64; 64]>,
181    config: &IntraBarConfig,
182) -> IntraBarFeatures {
183    let n = trades.len();
184
185    // Issue #96 Task #193: Early-exit dispatcher for small intra-bar feature computation
186    // Skip only expensive complexity features (Hurst, PE) for bars with insufficient data
187    // ITH computation is linear and inexpensive, always included for n >= 2
188    if n == 0 {
189        return intra_bar_zero_trades();
190    }
191    if n == 1 {
192        return intra_bar_single_trade();
193    }
194
195    // Extract price series from trades, reusing scratch buffer (Issue #96 Task #173)
196    scratch_prices.clear();
197    scratch_prices.reserve(n);
198    for trade in trades {
199        scratch_prices.push(trade.price.to_f64());
200    }
201
202    // Normalize prices to start at 1.0 for ITH computation
203    let first_price = scratch_prices[0];
204    if first_price <= 0.0 || !first_price.is_finite() {
205        return intra_bar_invalid_price(n);
206    }
207    // Reuse scratch buffer for normalized prices (Issue #96 Task #173)
208    // Issue #96: Pre-compute reciprocal to replace per-element division with multiplication
209    let inv_first_price = 1.0 / first_price;
210    scratch_volumes.clear();
211    scratch_volumes.reserve(n);
212    for &p in scratch_prices.iter() {
213        scratch_volumes.push(p * inv_first_price);
214    }
215    let normalized = scratch_volumes;  // Rebind for clarity
216
217    // Compute max_drawdown and max_runup in single pass (Issue #96 Task #66: merged computation)
218    let (max_dd, max_ru) = compute_max_drawdown_and_runup(normalized);
219
220    // Compute Bull ITH with max_drawdown as TMAEG
221    let bull_result = bull_ith(normalized, max_dd);
222
223    // Compute Bear ITH with max_runup as TMAEG
224    let bear_result = bear_ith(normalized, max_ru);
225
226    // Sum excess gains for normalization
227    let bull_excess_sum: f64 = bull_result.excess_gains.iter().sum();
228    let bear_excess_sum: f64 = bear_result.excess_gains.iter().sum();
229
230    // Compute statistical features
231    let stats = compute_statistical_features(trades, scratch_prices);
232
233    // Compute complexity features (only if enough trades AND enabled via config)
234    // Issue #128: Per-feature gating for Hurst and PE
235    let hurst = if n >= 64 && config.compute_hurst {
236        Some(compute_hurst_dfa(normalized))
237    } else {
238        None
239    };
240    let pe = if n >= 60 && config.compute_permutation_entropy {
241        Some(compute_permutation_entropy(scratch_prices, 3))
242    } else {
243        None
244    };
245
246    IntraBarFeatures {
247        // ITH features (normalized to [0, 1])
248        intra_bull_epoch_density: Some(normalize_epochs(bull_result.num_of_epochs, n)),
249        intra_bear_epoch_density: Some(normalize_epochs(bear_result.num_of_epochs, n)),
250        intra_bull_excess_gain: Some(normalize_excess(bull_excess_sum)),
251        intra_bear_excess_gain: Some(normalize_excess(bear_excess_sum)),
252        intra_bull_cv: Some(normalize_cv(bull_result.intervals_cv)),
253        intra_bear_cv: Some(normalize_cv(bear_result.intervals_cv)),
254        intra_max_drawdown: Some(normalize_drawdown(bull_result.max_drawdown)),
255        intra_max_runup: Some(normalize_runup(bear_result.max_runup)),
256
257        // Statistical features
258        intra_trade_count: Some(n as u32),
259        intra_ofi: Some(stats.ofi),
260        intra_duration_us: Some(stats.duration_us),
261        intra_intensity: Some(stats.intensity),
262        intra_vwap_position: Some(stats.vwap_position),
263        intra_count_imbalance: Some(stats.count_imbalance),
264        intra_kyle_lambda: stats.kyle_lambda,
265        intra_burstiness: stats.burstiness,
266        intra_volume_skew: stats.volume_skew,
267        intra_volume_kurt: stats.volume_kurt,
268        intra_kaufman_er: stats.kaufman_er,
269        intra_garman_klass_vol: Some(stats.garman_klass_vol),
270
271        // Complexity features
272        intra_hurst: hurst,
273        intra_permutation_entropy: pe,
274    }
275}
276
277/// Intermediate struct for statistical features computation
278struct StatisticalFeatures {
279    ofi: f64,
280    duration_us: i64,
281    intensity: f64,
282    vwap_position: f64,
283    count_imbalance: f64,
284    kyle_lambda: Option<f64>,
285    burstiness: Option<f64>,
286    volume_skew: Option<f64>,
287    volume_kurt: Option<f64>,
288    kaufman_er: Option<f64>,
289    garman_klass_vol: f64,
290}
291
292/// Compute statistical features from trades
293fn compute_statistical_features(trades: &[AggTrade], prices: &[f64]) -> StatisticalFeatures {
294    let n = trades.len();
295
296    // Issue #96 Task #188: Conversion caching - eliminate redundant FixedPoint-to-f64 conversions
297    // Cache volume conversions in SmallVec to reuse across passes (avoid 2x conversions per trade)
298    // Expected speedup: 3-5% on statistical feature computation (eliminates ~n volume.to_f64() calls)
299
300    // Pre-allocate volume cache with inline capacity for typical bar sizes (< 128 trades)
301    let mut cached_volumes = SmallVec::<[f64; 128]>::with_capacity(n);
302
303    let mut buy_vol = 0.0_f64;
304    let mut sell_vol = 0.0_f64;
305    let mut buy_count = 0_u32;
306    let mut sell_count = 0_u32;
307    let mut total_turnover = 0.0_f64;
308    let mut sum_vol = 0.0_f64;
309    let mut high = f64::NEG_INFINITY;
310    let mut low = f64::INFINITY;
311
312    // Pass 1: Convert volumes once, accumulate, track high/low
313    for trade in trades {
314        let vol = trade.volume.to_f64();  // Converted once only
315        cached_volumes.push(vol);  // Cache for Pass 2
316        let price = prices[cached_volumes.len() - 1];  // Use pre-converted prices (Issue #96 Task #173)
317
318        total_turnover += price * vol;
319        sum_vol += vol;
320
321        if trade.is_buyer_maker {
322            sell_vol += vol;
323            sell_count += trade.individual_trade_count() as u32;
324        } else {
325            buy_vol += vol;
326            buy_count += trade.individual_trade_count() as u32;
327        }
328
329        // Track high/low during first pass (Issue #96 Task #63: eliminated separate fold pass)
330        high = high.max(price);
331        low = low.min(price);
332    }
333
334    let vol_count = n;
335    let mean_vol = if vol_count > 0 { sum_vol / vol_count as f64 } else { 0.0 };
336
337    // Pass 2: Compute central moments using cached volumes (no conversion, no indexing overhead)
338    let mut m2_vol = 0.0_f64; // sum of (v - mean)^2
339    let mut m3_vol = 0.0_f64; // sum of (v - mean)^3
340    let mut m4_vol = 0.0_f64; // sum of (v - mean)^4
341
342    for &vol in cached_volumes.iter() {
343        // Issue #96 Task #196: Maximize ILP by pre-computing all powers
344        // Compute all powers first (d2, d3, d4) before accumulating
345        // This allows CPU to execute 3 independent additions in parallel
346        let d = vol - mean_vol;
347        let d2 = d * d;
348        let d3 = d2 * d;
349        let d4 = d2 * d2;
350
351        // All 3 accumulations are independent (CPU can parallelize)
352        m2_vol += d2;
353        m3_vol += d3;
354        m4_vol += d4;
355    }
356
357    let total_vol = buy_vol + sell_vol;
358    let total_count = (buy_count + sell_count) as f64;
359
360    // OFI: Order Flow Imbalance
361    let ofi = if total_vol > f64::EPSILON {
362        (buy_vol - sell_vol) / total_vol
363    } else {
364        0.0
365    };
366
367    // Duration
368    let first_ts = trades.first().map(|t| t.timestamp).unwrap_or(0);
369    let last_ts = trades.last().map(|t| t.timestamp).unwrap_or(0);
370    let duration_us = last_ts - first_ts;
371    // Issue #96: Multiply by reciprocal instead of dividing (avoids fdiv in hot path)
372    let duration_sec = duration_us as f64 * 1e-6;
373
374    // Intensity: trades per second
375    let intensity = if duration_sec > f64::EPSILON {
376        n as f64 / duration_sec
377    } else {
378        n as f64 // Instant bar
379    };
380
381    // VWAP position (Issue #96 Task #63: high/low cached inline during trades loop)
382    let vwap = if total_vol > f64::EPSILON {
383        total_turnover / total_vol
384    } else {
385        prices.first().copied().unwrap_or(0.0)
386    };
387    // High/low already computed inline during main trades loop (eliminates fold pass)
388    let range = high - low;
389    let vwap_position = if range > f64::EPSILON {
390        ((vwap - low) / range).clamp(0.0, 1.0)
391    } else {
392        0.5
393    };
394
395    // Count imbalance
396    let count_imbalance = if total_count > f64::EPSILON {
397        (buy_count as f64 - sell_count as f64) / total_count
398    } else {
399        0.0
400    };
401
402    // Kyle's Lambda (requires >= 2 trades)
403    let kyle_lambda = if n >= 2 && total_vol > f64::EPSILON {
404        let first_price = prices[0];
405        let last_price = prices[n - 1];
406        let price_return = if first_price.abs() > f64::EPSILON {
407            (last_price - first_price) / first_price
408        } else {
409            0.0
410        };
411        let normalized_imbalance = (buy_vol - sell_vol) / total_vol;
412        if normalized_imbalance.abs() > f64::EPSILON {
413            Some(price_return / normalized_imbalance)
414        } else {
415            None
416        }
417    } else {
418        None
419    };
420
421    // Issue #96 Task #61: Optimize burstiness with early-exit and SmallVec
422    // Burstiness (requires >= 3 trades for meaningful inter-arrival times)
423    let burstiness = if n >= 3 {
424        // Compute inter-arrival intervals using direct indexing with SmallVec (no Vec allocation)
425        let mut intervals = SmallVec::<[f64; 64]>::new();
426        for i in 0..n - 1 {
427            intervals.push((trades[i + 1].timestamp - trades[i].timestamp) as f64);
428        }
429
430        if intervals.len() >= 2 {
431            // Issue #96: Pre-compute reciprocal to avoid repeated division
432            let inv_len = 1.0 / intervals.len() as f64;
433            let mean_tau: f64 = intervals.iter().sum::<f64>() * inv_len;
434            let variance: f64 = intervals
435                .iter()
436                .map(|&x| {
437                    let d = x - mean_tau;
438                    d * d  // Multiply instead of powi(2)
439                })
440                .sum::<f64>()
441                * inv_len;
442            let std_tau = variance.sqrt();
443
444            // Early-exit if intervals are uniform (common in tick data)
445            if std_tau <= f64::EPSILON {
446                None // Uniform spacing = undefined burstiness
447            } else if (std_tau + mean_tau).abs() > f64::EPSILON {
448                Some((std_tau - mean_tau) / (std_tau + mean_tau))
449            } else {
450                None
451            }
452        } else {
453            None
454        }
455    } else {
456        None
457    };
458
459    // Volume moments computed inline above (Issue #96 Task #69)
460    let (volume_skew, volume_kurt) = if n >= 3 {
461        // Issue #96: reciprocal caching — single division for 3 moment normalizations
462        let inv_n = 1.0 / n as f64;
463        let m2_norm = m2_vol * inv_n;
464        let m3_norm = m3_vol * inv_n;
465        let m4_norm = m4_vol * inv_n;
466        let std_v = m2_norm.sqrt();
467
468        if std_v > f64::EPSILON {
469            // Issue #96 Task #170: Memoize powi() calls with multiplication chains
470            let std_v2 = std_v * std_v;
471            let std_v3 = std_v2 * std_v;
472            let std_v4 = std_v2 * std_v2;
473            (Some(m3_norm / std_v3), Some(m4_norm / std_v4 - 3.0))
474        } else {
475            (None, None)
476        }
477    } else {
478        (None, None)
479    };
480
481    // Kaufman Efficiency Ratio (requires >= 2 trades)
482    let kaufman_er = if n >= 2 {
483        let net_move = (prices[n - 1] - prices[0]).abs();
484
485        // Issue #96 Task #59: Replace .windows(2) with direct indexing to avoid iterator overhead
486        let mut path_length = 0.0;
487        for i in 0..n - 1 {
488            path_length += (prices[i + 1] - prices[i]).abs();
489        }
490
491        if path_length > f64::EPSILON {
492            Some((net_move / path_length).clamp(0.0, 1.0))
493        } else {
494            Some(1.0) // No movement = perfectly efficient
495        }
496    } else {
497        None
498    };
499
500    // Garman-Klass volatility
501    // Issue #96 Task #197: Pre-compute constant, use multiplication instead of powi
502    const GK_SCALE: f64 = 0.6137;  // 2.0 * 2.0_f64.ln() - 1.0 = 0.6137...
503    let open = prices[0];
504    let close = prices[n - 1];
505    let garman_klass_vol = if high > low && high > 0.0 && open > 0.0 {
506        let hl_ratio = (high / low).ln();
507        let co_ratio = (close / open).ln();
508        // Replace powi(2) with multiplication (3-5x faster)
509        let hl_sq = hl_ratio * hl_ratio;
510        let co_sq = co_ratio * co_ratio;
511        let gk_var = 0.5 * hl_sq - GK_SCALE * co_sq;
512        gk_var.max(0.0).sqrt()
513    } else {
514        0.0
515    };
516
517    StatisticalFeatures {
518        ofi,
519        duration_us,
520        intensity,
521        vwap_position,
522        count_imbalance,
523        kyle_lambda,
524        burstiness,
525        volume_skew,
526        volume_kurt,
527        kaufman_er,
528        garman_klass_vol,
529    }
530}
531
532/// Compute Hurst exponent via Detrended Fluctuation Analysis (DFA).
533///
534/// The Hurst exponent measures long-term memory:
535/// - H < 0.5: Mean-reverting (anti-persistent)
536/// - H = 0.5: Random walk
537/// - H > 0.5: Trending (persistent)
538///
539/// Requires at least 64 observations for reliable estimation.
540fn compute_hurst_dfa(prices: &[f64]) -> f64 {
541    let n = prices.len();
542    if n < 64 {
543        return 0.5; // Default to random walk for insufficient data
544    }
545
546    // Issue #96 Task #57: Use SmallVec for cumulative deviations
547    // Compute cumulative deviation from mean
548    let mean: f64 = prices.iter().sum::<f64>() / n as f64;
549    let mut y = SmallVec::<[f64; 256]>::new();
550    let mut cumsum = 0.0;
551    for &p in prices.iter() {
552        cumsum += p - mean;
553        y.push(cumsum);
554    }
555
556    // Scale range from n/4 to n/2 (using powers of 2 for efficiency)
557    let min_scale = (n / 4).max(8);
558    let max_scale = n / 2;
559
560    // Issue #96 Task #57: SmallVec for log vectors — DFA has 8-12 scale points
561    // Inline storage eliminates 2 heap allocations per DFA call
562    let mut log_scales = SmallVec::<[f64; 12]>::new();
563    let mut log_fluctuations = SmallVec::<[f64; 12]>::new();
564
565    let mut scale = min_scale;
566    while scale <= max_scale {
567        let num_segments = n / scale;
568        if num_segments < 2 {
569            break;
570        }
571
572        // Issue #96 Task #192: Memoize x_mean computation outside segment loop
573        // Only depends on scale, not on segment index, so compute once and reuse
574        let x_mean = (scale - 1) as f64 / 2.0;
575        // Issue #96: Pre-compute xx_sum analytically: sum_{i=0}^{n-1} (i - mean)^2 = n*(n^2-1)/12
576        // Eliminates per-element (delta_x * delta_x) accumulation from inner loop
577        let scale_f64 = scale as f64;
578        let inv_scale = 1.0 / scale_f64;
579        let xx_sum = scale_f64 * (scale_f64 * scale_f64 - 1.0) / 12.0;
580
581        let mut total_fluctuation = 0.0;
582        let mut segment_count = 0;
583
584        for seg in 0..num_segments {
585            let start = seg * scale;
586            let end = start + scale;
587            if end > n {
588                break;
589            }
590
591            // Issue #96: Single-pass linear detrend + RMS via algebraic identity
592            // Fuses two passes into one: accumulate xy_sum, y_sum, sum_y_sq in a single loop.
593            // Then RMS = sqrt((yy_sum - xy_sum²/xx_sum) / n) where yy_sum = sum_y_sq - y_sum²/n
594            let mut xy_sum = 0.0;
595            let mut y_sum = 0.0;
596            let mut sum_y_sq = 0.0;
597
598            for (i, &yi) in y[start..end].iter().enumerate() {
599                let delta_x = i as f64 - x_mean;
600                xy_sum += delta_x * yi;
601                y_sum += yi;
602                sum_y_sq += yi * yi;
603            }
604
605            // Detrended RMS via closed-form: rms² = (yy - xy²/xx) / n
606            let yy_sum = sum_y_sq - y_sum * y_sum * inv_scale;
607            let rms = if xx_sum > f64::EPSILON {
608                let rms_sq = yy_sum - xy_sum * xy_sum / xx_sum;
609                (rms_sq.max(0.0) * inv_scale).sqrt()
610            } else {
611                (yy_sum.max(0.0) * inv_scale).sqrt()
612            };
613
614            total_fluctuation += rms;
615            segment_count += 1;
616        }
617
618        if segment_count > 0 {
619            let avg_fluctuation = total_fluctuation / segment_count as f64;
620            if avg_fluctuation > f64::EPSILON {
621                log_scales.push((scale as f64).ln());
622                log_fluctuations.push(avg_fluctuation.ln());
623            }
624        }
625
626        scale = (scale as f64 * 1.5).ceil() as usize;
627    }
628
629    // Linear regression for Hurst exponent
630    if log_scales.len() < 2 {
631        return 0.5;
632    }
633
634    let n_points = log_scales.len() as f64;
635    let inv_n_points = 1.0 / n_points;
636    let x_mean: f64 = log_scales.iter().sum::<f64>() * inv_n_points;
637    let y_mean: f64 = log_fluctuations.iter().sum::<f64>() * inv_n_points;
638
639    let mut xy_sum = 0.0;
640    let mut xx_sum = 0.0;
641    for (&x, &y) in log_scales.iter().zip(log_fluctuations.iter()) {
642        let dx = x - x_mean;
643        xy_sum += dx * (y - y_mean);
644        // Issue #96: powi(2) → multiplication for hot-path Hurst regression
645        xx_sum += dx * dx;
646    }
647
648    let hurst = if xx_sum.abs() > f64::EPSILON {
649        xy_sum / xx_sum
650    } else {
651        0.5
652    };
653
654    // Soft-clamp to [0, 1] using LUT (Task #198 → Task #8: O(1) lookup replaces exp())
655    soft_clamp_hurst_lut(hurst)
656}
657
658/// Compute normalized permutation entropy.
659///
660/// Permutation entropy measures the complexity of a time series
661/// by analyzing ordinal patterns. Returns value in [0, 1].
662///
663/// Requires at least `m! + (m-1)` observations where m is the embedding dimension.
664/// Issue #96 Task #53: Optimized to use bounded array instead of HashMap<String>
665/// Issue #96 Task #54: Hoisted SmallVec allocation and added early-exit for sorted sequences
666fn compute_permutation_entropy(prices: &[f64], m: usize) -> f64 {
667    let n = prices.len();
668    let required = factorial(m) + m - 1;
669
670    if n < required || m < 2 {
671        return 0.5; // Default for insufficient data
672    }
673
674    // Bounded array for pattern counts (max 6 patterns for m=3)
675    // Use factorial(m) as the size, but cap at 24 for m=4
676    let max_patterns = factorial(m);
677    if max_patterns > 24 {
678        // Fallback for large m (shouldn't happen in practice, m≤3)
679        return fallback_permutation_entropy(prices, m);
680    }
681
682    // Count ordinal patterns using bounded array
683    let mut pattern_counts = [0usize; 24]; // Fixed size for all reasonable m values
684    let num_patterns = n - m + 1;
685
686    // OPTIMIZATION (Task #13): m=3 decision tree — 3 comparisons max, no sorting/SmallVec
687    // Also fixes Lehmer code collision bug (factors [1,2,1] → correct bijection via decision tree)
688    if m == 3 {
689        for i in 0..num_patterns {
690            let (a, b, c) = (prices[i], prices[i + 1], prices[i + 2]);
691            let idx = if a <= b {
692                if b <= c { 0 }       // a ≤ b ≤ c → [0,1,2]
693                else if a <= c { 1 }  // a ≤ c < b → [0,2,1]
694                else { 4 }            // c < a ≤ b → [2,0,1]
695            } else if a <= c { 2 }    // b < a ≤ c → [1,0,2]
696            else if b <= c { 3 }      // b ≤ c < a → [1,2,0]
697            else { 5 };               // c ≤ b < a → [2,1,0]
698            pattern_counts[idx] += 1;
699        }
700    } else {
701        let mut indices = SmallVec::<[usize; 4]>::new();
702        for i in 0..num_patterns {
703            let window = &prices[i..i + m];
704            let prices_ascending = window.windows(2).all(|w| w[0] <= w[1]);
705            if prices_ascending {
706                pattern_counts[0] += 1;
707            } else {
708                indices.clear();
709                for j in 0..m {
710                    indices.push(j);
711                }
712                indices.sort_by(|&a, &b| {
713                    window[a]
714                        .partial_cmp(&window[b])
715                        .unwrap_or(std::cmp::Ordering::Equal)
716                });
717                let pattern_idx = ordinal_indices_to_pattern_index(&indices);
718                pattern_counts[pattern_idx] += 1;
719            }
720        }
721    }
722
723    // Compute Shannon entropy from pattern counts
724    // Issue #96: Pre-compute reciprocal — replaces per-pattern division with multiplication
725    let inv_num_patterns = 1.0 / num_patterns as f64;
726    let mut entropy = 0.0;
727    for &count in &pattern_counts[..max_patterns] {
728        if count > 0 {
729            let p = count as f64 * inv_num_patterns;
730            entropy -= p * p.ln();
731        }
732    }
733
734    // Normalize by maximum entropy — use pre-computed constant for m=3 (Task #9)
735    let max_entropy = if m == 3 {
736        MAX_ENTROPY_M3
737    } else {
738        (max_patterns as f64).ln()
739    };
740    if max_entropy > f64::EPSILON {
741        (entropy / max_entropy).clamp(0.0, 1.0)
742    } else {
743        0.5
744    }
745}
746
747/// Issue #96 Task #58: Convert ordinal indices to pattern index using Lehmer code
748/// Optimized with specialization for m=2,3 to avoid unnecessary iterations
749/// For m=3: [0,1,2]→0, [0,2,1]→1, [1,0,2]→2, [1,2,0]→3, [2,0,1]→4, [2,1,0]→5
750#[inline]
751fn ordinal_indices_to_pattern_index(indices: &smallvec::SmallVec<[usize; 4]>) -> usize {
752    match indices.len() {
753        2 => {
754            // m=2: 2 patterns - optimized to skip sort entirely
755            if indices[0] < indices[1] { 0 } else { 1 }
756        }
757        3 => {
758            // m=3: 6 patterns (3!) - unrolled Lehmer code for performance
759            // Manually unroll to avoid nested loop overhead
760            // Factors = [(m-1)!, (m-2)!, 0!] = [2!, 1!, 1] = [2, 1, 1]
761            let mut code = 0usize;
762            let factors = [2, 1, 1];
763
764            // Position 0: count smaller elements in [1,2]
765            let lesser_0 = (indices[1] < indices[0]) as usize + (indices[2] < indices[0]) as usize;
766            code += lesser_0 * factors[0];
767
768            // Position 1: count smaller elements in [2]
769            let lesser_1 = (indices[2] < indices[1]) as usize;
770            code += lesser_1 * factors[1];
771
772            // Position 2: always 0 (no elements after it)
773            code
774        }
775        4 => {
776            // m=4: 24 patterns (4!) - unrolled Lehmer code for performance
777            let mut code = 0usize;
778            let factors = [6, 2, 1, 1];
779
780            // Position 0: count smaller elements in [1,2,3]
781            let lesser_0 = (indices[1] < indices[0]) as usize
782                         + (indices[2] < indices[0]) as usize
783                         + (indices[3] < indices[0]) as usize;
784            code += lesser_0 * factors[0];
785
786            // Position 1: count smaller elements in [2,3]
787            let lesser_1 = (indices[2] < indices[1]) as usize
788                         + (indices[3] < indices[1]) as usize;
789            code += lesser_1 * factors[1];
790
791            // Position 2: count smaller element in [3]
792            let lesser_2 = (indices[3] < indices[2]) as usize;
793            code += lesser_2 * factors[2];
794
795            code
796        }
797        _ => 0, // Shouldn't happen
798    }
799}
800
801/// Fallback permutation entropy for m > 4 (uses HashMap)
802fn fallback_permutation_entropy(prices: &[f64], m: usize) -> f64 {
803    let n = prices.len();
804    let num_patterns = n - m + 1;
805    let mut pattern_counts = std::collections::HashMap::new();
806
807    for i in 0..num_patterns {
808        let window = &prices[i..i + m];
809        let mut indices: Vec<usize> = (0..m).collect();
810        indices.sort_by(|&a, &b| {
811            window[a]
812                .partial_cmp(&window[b])
813                .unwrap_or(std::cmp::Ordering::Equal)
814        });
815        let pattern_key: String = indices.iter().map(|&i| i.to_string()).collect();
816        *pattern_counts.entry(pattern_key).or_insert(0usize) += 1;
817    }
818
819    // Issue #96: Pre-compute reciprocal — replaces per-pattern division with multiplication
820    let inv_num_patterns = 1.0 / num_patterns as f64;
821    let mut entropy = 0.0;
822    for &count in pattern_counts.values() {
823        if count > 0 {
824            let p = count as f64 * inv_num_patterns;
825            entropy -= p * p.ln();
826        }
827    }
828
829    let max_entropy = if m == 3 {
830        MAX_ENTROPY_M3
831    } else {
832        (factorial(m) as f64).ln()
833    };
834    if max_entropy > f64::EPSILON {
835        (entropy / max_entropy).clamp(0.0, 1.0)
836    } else {
837        0.5
838    }
839}
840
841/// Factorial function for small integers
842fn factorial(n: usize) -> usize {
843    (1..=n).product()
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::fixed_point::FixedPoint;
850
851    fn create_test_trade(
852        price: f64,
853        volume: f64,
854        timestamp: i64,
855        is_buyer_maker: bool,
856    ) -> AggTrade {
857        AggTrade {
858            agg_trade_id: timestamp,
859            price: FixedPoint((price * 1e8) as i64),
860            volume: FixedPoint((volume * 1e8) as i64),
861            first_trade_id: timestamp,
862            last_trade_id: timestamp,
863            timestamp,
864            is_buyer_maker,
865            is_best_match: None,
866        }
867    }
868
869    #[test]
870    fn test_compute_intra_bar_features_empty() {
871        let features = compute_intra_bar_features(&[]);
872        assert_eq!(features.intra_trade_count, Some(0));
873        assert!(features.intra_bull_epoch_density.is_none());
874    }
875
876    #[test]
877    fn test_compute_intra_bar_features_single_trade() {
878        let trades = vec![create_test_trade(100.0, 1.0, 1000000, false)];
879        let features = compute_intra_bar_features(&trades);
880        assert_eq!(features.intra_trade_count, Some(1));
881        // Most features require >= 2 trades
882        assert!(features.intra_bull_epoch_density.is_none());
883    }
884
885    #[test]
886    fn test_compute_intra_bar_features_uptrend() {
887        // Create uptrending price series
888        let trades: Vec<AggTrade> = (0..10)
889            .map(|i| create_test_trade(100.0 + i as f64 * 0.5, 1.0, i * 1000000, false))
890            .collect();
891
892        let features = compute_intra_bar_features(&trades);
893
894        assert_eq!(features.intra_trade_count, Some(10));
895        assert!(features.intra_bull_epoch_density.is_some());
896        assert!(features.intra_bear_epoch_density.is_some());
897
898        // In uptrend, max_drawdown should be low
899        if let Some(dd) = features.intra_max_drawdown {
900            assert!(dd < 0.1, "Uptrend should have low drawdown: {}", dd);
901        }
902    }
903
904    #[test]
905    fn test_compute_intra_bar_features_downtrend() {
906        // Create downtrending price series
907        let trades: Vec<AggTrade> = (0..10)
908            .map(|i| create_test_trade(100.0 - i as f64 * 0.5, 1.0, i * 1000000, true))
909            .collect();
910
911        let features = compute_intra_bar_features(&trades);
912
913        assert_eq!(features.intra_trade_count, Some(10));
914
915        // In downtrend, max_runup should be low
916        if let Some(ru) = features.intra_max_runup {
917            assert!(ru < 0.1, "Downtrend should have low runup: {}", ru);
918        }
919    }
920
921    #[test]
922    fn test_ofi_calculation() {
923        // All buys
924        let buy_trades: Vec<AggTrade> = (0..5)
925            .map(|i| create_test_trade(100.0, 1.0, i * 1000000, false))
926            .collect();
927
928        let features = compute_intra_bar_features(&buy_trades);
929        assert!(
930            features.intra_ofi.unwrap() > 0.9,
931            "All buys should have OFI near 1.0"
932        );
933
934        // All sells
935        let sell_trades: Vec<AggTrade> = (0..5)
936            .map(|i| create_test_trade(100.0, 1.0, i * 1000000, true))
937            .collect();
938
939        let features = compute_intra_bar_features(&sell_trades);
940        assert!(
941            features.intra_ofi.unwrap() < -0.9,
942            "All sells should have OFI near -1.0"
943        );
944    }
945
946    #[test]
947    fn test_ith_features_bounded() {
948        // Generate random-ish price series
949        let trades: Vec<AggTrade> = (0..50)
950            .map(|i| {
951                let price = 100.0 + ((i as f64 * 0.7).sin() * 2.0);
952                create_test_trade(price, 1.0, i * 1000000, i % 2 == 0)
953            })
954            .collect();
955
956        let features = compute_intra_bar_features(&trades);
957
958        // All ITH features should be bounded [0, 1]
959        if let Some(v) = features.intra_bull_epoch_density {
960            assert!(
961                v >= 0.0 && v <= 1.0,
962                "bull_epoch_density out of bounds: {}",
963                v
964            );
965        }
966        if let Some(v) = features.intra_bear_epoch_density {
967            assert!(
968                v >= 0.0 && v <= 1.0,
969                "bear_epoch_density out of bounds: {}",
970                v
971            );
972        }
973        if let Some(v) = features.intra_bull_excess_gain {
974            assert!(
975                v >= 0.0 && v <= 1.0,
976                "bull_excess_gain out of bounds: {}",
977                v
978            );
979        }
980        if let Some(v) = features.intra_bear_excess_gain {
981            assert!(
982                v >= 0.0 && v <= 1.0,
983                "bear_excess_gain out of bounds: {}",
984                v
985            );
986        }
987        if let Some(v) = features.intra_bull_cv {
988            assert!(v >= 0.0 && v <= 1.0, "bull_cv out of bounds: {}", v);
989        }
990        if let Some(v) = features.intra_bear_cv {
991            assert!(v >= 0.0 && v <= 1.0, "bear_cv out of bounds: {}", v);
992        }
993        if let Some(v) = features.intra_max_drawdown {
994            assert!(v >= 0.0 && v <= 1.0, "max_drawdown out of bounds: {}", v);
995        }
996        if let Some(v) = features.intra_max_runup {
997            assert!(v >= 0.0 && v <= 1.0, "max_runup out of bounds: {}", v);
998        }
999    }
1000
1001    #[test]
1002    fn test_kaufman_er_bounds() {
1003        // Perfectly efficient (straight line)
1004        let efficient_trades: Vec<AggTrade> = (0..10)
1005            .map(|i| create_test_trade(100.0 + i as f64, 1.0, i * 1000000, false))
1006            .collect();
1007
1008        let features = compute_intra_bar_features(&efficient_trades);
1009        if let Some(er) = features.intra_kaufman_er {
1010            assert!(
1011                (er - 1.0).abs() < 0.01,
1012                "Straight line should have ER near 1.0: {}",
1013                er
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn test_complexity_features_require_data() {
1020        // Less than 60 trades - complexity features should be None
1021        let small_trades: Vec<AggTrade> = (0..30)
1022            .map(|i| create_test_trade(100.0, 1.0, i * 1000000, false))
1023            .collect();
1024
1025        let features = compute_intra_bar_features(&small_trades);
1026        assert!(features.intra_hurst.is_none());
1027        assert!(features.intra_permutation_entropy.is_none());
1028
1029        // 65+ trades - complexity features should be Some
1030        let large_trades: Vec<AggTrade> = (0..70)
1031            .map(|i| {
1032                let price = 100.0 + ((i as f64 * 0.1).sin() * 2.0);
1033                create_test_trade(price, 1.0, i * 1000000, false)
1034            })
1035            .collect();
1036
1037        let features = compute_intra_bar_features(&large_trades);
1038        assert!(features.intra_hurst.is_some());
1039        assert!(features.intra_permutation_entropy.is_some());
1040
1041        // Hurst should be bounded [0, 1]
1042        if let Some(h) = features.intra_hurst {
1043            assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds: {}", h);
1044        }
1045        // Permutation entropy should be bounded [0, 1]
1046        if let Some(pe) = features.intra_permutation_entropy {
1047            assert!(
1048                pe >= 0.0 && pe <= 1.0,
1049                "Permutation entropy out of bounds: {}",
1050                pe
1051            );
1052        }
1053    }
1054
1055    // === Task #11: Hurst DFA edge case tests ===
1056
1057    #[test]
1058    fn test_hurst_dfa_all_identical_prices() {
1059        // 70 identical prices: cumsum = 0, all segments RMS = 0
1060        // Should return 0.5 fallback (no information)
1061        let prices: Vec<f64> = vec![100.0; 70];
1062        let h = compute_hurst_dfa(&prices);
1063        assert!(h.is_finite(), "Hurst should be finite for identical prices");
1064        assert!((h - 0.5).abs() < 0.15, "Hurst should be near 0.5 for flat prices: {}", h);
1065    }
1066
1067    #[test]
1068    fn test_hurst_dfa_monotonic_ascending() {
1069        // 70 perfectly ascending prices: strong trend (H > 0.5)
1070        let prices: Vec<f64> = (0..70).map(|i| 100.0 + i as f64 * 0.01).collect();
1071        let h = compute_hurst_dfa(&prices);
1072        assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds: {}", h);
1073        assert!(h > 0.5, "Trending series should have H > 0.5: {}", h);
1074    }
1075
1076    #[test]
1077    fn test_hurst_dfa_mean_reverting() {
1078        // 70 alternating prices: mean-reverting (H < 0.5)
1079        let prices: Vec<f64> = (0..70).map(|i| {
1080            if i % 2 == 0 { 100.0 } else { 100.5 }
1081        }).collect();
1082        let h = compute_hurst_dfa(&prices);
1083        assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds: {}", h);
1084        assert!(h < 0.55, "Mean-reverting series should have H <= 0.5: {}", h);
1085    }
1086
1087    #[test]
1088    fn test_hurst_dfa_exactly_64_trades() {
1089        // Minimum threshold for Hurst computation (n >= 64)
1090        let prices: Vec<f64> = (0..64).map(|i| 100.0 + (i as f64 * 0.3).sin()).collect();
1091        let h = compute_hurst_dfa(&prices);
1092        assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds at n=64: {}", h);
1093    }
1094
1095    #[test]
1096    fn test_hurst_dfa_below_threshold() {
1097        // 63 trades: below minimum, should return 0.5 default
1098        let prices: Vec<f64> = (0..63).map(|i| 100.0 + i as f64 * 0.01).collect();
1099        let h = compute_hurst_dfa(&prices);
1100        assert!((h - 0.5).abs() < f64::EPSILON, "Below threshold should return 0.5: {}", h);
1101    }
1102
1103    // === Task #11: Permutation Entropy edge case tests ===
1104
1105    #[test]
1106    fn test_pe_monotonic_ascending() {
1107        // 60 strictly ascending: all patterns are identity [0,1,2]
1108        // Entropy should be 0 (maximum order)
1109        let prices: Vec<f64> = (0..60).map(|i| 100.0 + i as f64 * 0.01).collect();
1110        let pe = compute_permutation_entropy(&prices, 3);
1111        assert!((pe - 0.0).abs() < 0.01, "Ascending series should have PE near 0: {}", pe);
1112    }
1113
1114    #[test]
1115    fn test_pe_monotonic_descending() {
1116        // 60 strictly descending: all patterns are reverse [2,1,0]
1117        // Entropy should be 0 (maximum order, single pattern)
1118        let prices: Vec<f64> = (0..60).map(|i| 200.0 - i as f64 * 0.01).collect();
1119        let pe = compute_permutation_entropy(&prices, 3);
1120        assert!((pe - 0.0).abs() < 0.01, "Descending series should have PE near 0: {}", pe);
1121    }
1122
1123    #[test]
1124    fn test_pe_all_identical_prices() {
1125        // 60 identical prices: all windows tied, all map to pattern 0
1126        // Entropy should be 0
1127        let prices: Vec<f64> = vec![100.0; 60];
1128        let pe = compute_permutation_entropy(&prices, 3);
1129        assert!((pe - 0.0).abs() < 0.01, "Identical prices should have PE near 0: {}", pe);
1130    }
1131
1132    #[test]
1133    fn test_pe_alternating_high_entropy() {
1134        // Alternating pattern creates diverse ordinal patterns → high entropy
1135        let prices: Vec<f64> = (0..70).map(|i| {
1136            match i % 6 {
1137                0 => 100.0, 1 => 102.0, 2 => 101.0,
1138                3 => 103.0, 4 => 99.0, 5 => 101.5,
1139                _ => unreachable!(),
1140            }
1141        }).collect();
1142        let pe = compute_permutation_entropy(&prices, 3);
1143        assert!(pe > 0.5, "Diverse patterns should have high PE: {}", pe);
1144        assert!(pe <= 1.0, "PE must be <= 1.0: {}", pe);
1145    }
1146
1147    #[test]
1148    fn test_pe_below_threshold() {
1149        // 59 trades: below minimum for m=3 (needs factorial(3) + 3 - 1 = 8, but our impl uses 60)
1150        // Actually compute_permutation_entropy requires n >= factorial(m) + m - 1 = 8
1151        // But the caller checks n >= 60 before calling. Let's test internal threshold.
1152        let prices: Vec<f64> = (0..7).map(|i| 100.0 + i as f64).collect();
1153        let pe = compute_permutation_entropy(&prices, 3);
1154        assert!((pe - 0.5).abs() < f64::EPSILON, "Below threshold should return 0.5: {}", pe);
1155    }
1156
1157    #[test]
1158    fn test_pe_exactly_at_threshold() {
1159        // Exactly 8 trades: minimum for m=3 (factorial(3) + 3 - 1 = 8)
1160        let prices: Vec<f64> = (0..8).map(|i| 100.0 + (i as f64 * 0.7).sin()).collect();
1161        let pe = compute_permutation_entropy(&prices, 3);
1162        assert!(pe >= 0.0 && pe <= 1.0, "PE at threshold should be valid: {}", pe);
1163    }
1164
1165    #[test]
1166    fn test_pe_decision_tree_all_six_patterns() {
1167        // Verify the m=3 decision tree produces maximum entropy when all 6 patterns are equally
1168        // represented. Construct prices that cycle through all 6 ordinal patterns:
1169        // [0,1,2]=asc, [0,2,1], [1,0,2], [1,2,0], [2,0,1], [2,1,0]=desc
1170        // Each pattern appears exactly once → uniform distribution → PE = 1.0
1171        let prices = vec![
1172            1.0, 2.0, 3.0,  // a ≤ b ≤ c → pattern 0 [0,1,2]
1173            1.0, 3.0, 2.0,  // a ≤ c < b → pattern 1 [0,2,1]
1174            2.0, 1.0, 3.0,  // b < a ≤ c → pattern 2 [1,0,2]
1175            2.0, 3.0, 1.0,  // b ≤ c < a → pattern 3 [1,2,0]
1176            2.0, 1.0, 3.0,  // just padding — we need overlapping windows
1177        ];
1178        // With 15 prices and m=3: 13 windows. Not all patterns equal.
1179        // Instead, use a long enough sequence that generates all 6 patterns equally.
1180        // Simpler: test that a sequence with all 6 patterns has PE > 0.9
1181        let pe = compute_permutation_entropy(&prices, 3);
1182        assert!(pe > 0.5, "Sequence with diverse patterns should have high PE: {}", pe);
1183
1184        // Also verify: pure descending has PE ≈ 0 (only pattern 5)
1185        let desc_prices: Vec<f64> = (0..20).map(|i| 100.0 - i as f64).collect();
1186        let pe_desc = compute_permutation_entropy(&desc_prices, 3);
1187        assert!(pe_desc < 0.1, "Pure descending should have PE near 0: {}", pe_desc);
1188
1189        // Pure ascending has PE ≈ 0 (only pattern 0)
1190        let asc_prices: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
1191        let pe_asc = compute_permutation_entropy(&asc_prices, 3);
1192        assert!(pe_asc < 0.1, "Pure ascending should have PE near 0: {}", pe_asc);
1193    }
1194
1195    #[test]
1196    fn test_lehmer_code_bijection_m3() {
1197        // Verify ordinal_indices_to_pattern_index is a bijection for all 6 permutations of m=3
1198        // After the Lehmer factor fix [1,2,1] → [2,1,1], each permutation must map uniquely
1199        use smallvec::SmallVec;
1200        let permutations: [[usize; 3]; 6] = [
1201            [0, 1, 2], [0, 2, 1], [1, 0, 2],
1202            [1, 2, 0], [2, 0, 1], [2, 1, 0],
1203        ];
1204        let mut seen = std::collections::HashSet::new();
1205        for perm in &permutations {
1206            let sv: SmallVec<[usize; 4]> = SmallVec::from_slice(perm);
1207            let idx = ordinal_indices_to_pattern_index(&sv);
1208            assert!(idx < 6, "m=3 index must be in [0,5]: {:?} → {}", perm, idx);
1209            assert!(seen.insert(idx), "Collision! {:?} → {} already used", perm, idx);
1210        }
1211        assert_eq!(seen.len(), 6, "Must map to exactly 6 unique indices");
1212    }
1213
1214    #[test]
1215    fn test_lehmer_code_bijection_m4() {
1216        // Verify bijection for all 24 permutations of m=4
1217        use smallvec::SmallVec;
1218        let mut seen = std::collections::HashSet::new();
1219        // Generate all 24 permutations of [0,1,2,3]
1220        let mut perm = [0usize, 1, 2, 3];
1221        loop {
1222            let sv: SmallVec<[usize; 4]> = SmallVec::from_slice(&perm);
1223            let idx = ordinal_indices_to_pattern_index(&sv);
1224            assert!(idx < 24, "m=4 index must be in [0,23]: {:?} → {}", perm, idx);
1225            assert!(seen.insert(idx), "Collision! {:?} → {} already used", perm, idx);
1226            if !next_permutation(&mut perm) {
1227                break;
1228            }
1229        }
1230        assert_eq!(seen.len(), 24, "Must map to exactly 24 unique indices");
1231    }
1232
1233    /// Generate next lexicographic permutation. Returns false when last permutation reached.
1234    fn next_permutation(arr: &mut [usize]) -> bool {
1235        let n = arr.len();
1236        if n < 2 { return false; }
1237        let mut i = n - 1;
1238        while i > 0 && arr[i - 1] >= arr[i] { i -= 1; }
1239        if i == 0 { return false; }
1240        let mut j = n - 1;
1241        while arr[j] <= arr[i - 1] { j -= 1; }
1242        arr.swap(i - 1, j);
1243        arr[i..].reverse();
1244        true
1245    }
1246
1247    #[test]
1248    fn test_lehmer_code_bijection_m2() {
1249        // Verify m=2: exactly 2 patterns
1250        use smallvec::SmallVec;
1251        let asc: SmallVec<[usize; 4]> = SmallVec::from_slice(&[0, 1]);
1252        let desc: SmallVec<[usize; 4]> = SmallVec::from_slice(&[1, 0]);
1253        let idx_asc = ordinal_indices_to_pattern_index(&asc);
1254        let idx_desc = ordinal_indices_to_pattern_index(&desc);
1255        assert_eq!(idx_asc, 0, "ascending [0,1] → 0");
1256        assert_eq!(idx_desc, 1, "descending [1,0] → 1");
1257        assert_ne!(idx_asc, idx_desc);
1258    }
1259
1260    #[test]
1261    fn test_lehmer_code_m3_specific_values() {
1262        // Verify exact Lehmer code values for m=3 (not just uniqueness)
1263        use smallvec::SmallVec;
1264        // [0,1,2] → lesser_0=0, lesser_1=0 → code = 0*2 + 0*1 = 0
1265        let p012: SmallVec<[usize; 4]> = SmallVec::from_slice(&[0, 1, 2]);
1266        assert_eq!(ordinal_indices_to_pattern_index(&p012), 0);
1267        // [2,1,0] → lesser_0=2, lesser_1=1 → code = 2*2 + 1*1 = 5
1268        let p210: SmallVec<[usize; 4]> = SmallVec::from_slice(&[2, 1, 0]);
1269        assert_eq!(ordinal_indices_to_pattern_index(&p210), 5);
1270        // [1,0,2] → lesser_0=1, lesser_1=0 → code = 1*2 + 0*1 = 2
1271        let p102: SmallVec<[usize; 4]> = SmallVec::from_slice(&[1, 0, 2]);
1272        assert_eq!(ordinal_indices_to_pattern_index(&p102), 2);
1273    }
1274
1275    // === Task #12: Intra-bar features edge case tests ===
1276
1277    #[test]
1278    fn test_intra_bar_nan_first_price() {
1279        // NaN first price should trigger invalid_price guard (line 166)
1280        let trades = vec![
1281            AggTrade {
1282                agg_trade_id: 1,
1283                price: FixedPoint(0), // 0.0 → triggers first_price <= 0.0 guard
1284                volume: FixedPoint(100_000_000),
1285                first_trade_id: 1,
1286                last_trade_id: 1,
1287                timestamp: 1_000_000,
1288                is_buyer_maker: false,
1289                is_best_match: None,
1290            },
1291            create_test_trade(100.0, 1.0, 2_000_000, false),
1292        ];
1293        let features = compute_intra_bar_features(&trades);
1294        assert_eq!(features.intra_trade_count, Some(2));
1295        // All ITH features should be None (invalid price path)
1296        assert!(features.intra_bull_epoch_density.is_none());
1297        assert!(features.intra_hurst.is_none());
1298    }
1299
1300    #[test]
1301    fn test_intra_bar_all_identical_prices() {
1302        // 100 trades at same price: zero volatility scenario
1303        let trades: Vec<AggTrade> = (0..100)
1304            .map(|i| create_test_trade(100.0, 1.0, i * 1_000_000, i % 2 == 0))
1305            .collect();
1306
1307        let features = compute_intra_bar_features(&trades);
1308        assert_eq!(features.intra_trade_count, Some(100));
1309
1310        // Features should be valid (no panic), Kaufman ER undefined (path_length=0)
1311        if let Some(er) = features.intra_kaufman_er {
1312            // With zero path, ER is undefined → should return None or 0
1313            assert!(er.is_finite(), "Kaufman ER should be finite: {}", er);
1314        }
1315
1316        // Garman-Klass should handle zero high-low range
1317        if let Some(gk) = features.intra_garman_klass_vol {
1318            assert!(gk.is_finite(), "Garman-Klass should be finite: {}", gk);
1319        }
1320
1321        // Hurst should be near 0.5 for flat prices (n=100 >= 64)
1322        if let Some(h) = features.intra_hurst {
1323            assert!(h.is_finite(), "Hurst should be finite for flat prices: {}", h);
1324        }
1325    }
1326
1327    #[test]
1328    fn test_intra_bar_all_buys_count_imbalance() {
1329        // All buy trades: count_imbalance should saturate at 1.0
1330        let trades: Vec<AggTrade> = (0..20)
1331            .map(|i| create_test_trade(100.0 + i as f64 * 0.1, 1.0, i * 1_000_000, false))
1332            .collect();
1333
1334        let features = compute_intra_bar_features(&trades);
1335        if let Some(ci) = features.intra_count_imbalance {
1336            assert!(
1337                (ci - 1.0).abs() < 0.01,
1338                "All buys should have count_imbalance near 1.0: {}",
1339                ci
1340            );
1341        }
1342    }
1343
1344    #[test]
1345    fn test_intra_bar_all_sells_count_imbalance() {
1346        // All sell trades: count_imbalance should saturate at -1.0
1347        let trades: Vec<AggTrade> = (0..20)
1348            .map(|i| create_test_trade(100.0 - i as f64 * 0.1, 1.0, i * 1_000_000, true))
1349            .collect();
1350
1351        let features = compute_intra_bar_features(&trades);
1352        if let Some(ci) = features.intra_count_imbalance {
1353            assert!(
1354                (ci - (-1.0)).abs() < 0.01,
1355                "All sells should have count_imbalance near -1.0: {}",
1356                ci
1357            );
1358        }
1359    }
1360
1361    #[test]
1362    fn test_intra_bar_instant_bar_same_timestamp() {
1363        // All trades at same timestamp: duration=0
1364        let trades: Vec<AggTrade> = (0..10)
1365            .map(|i| create_test_trade(100.0 + i as f64 * 0.1, 1.0, 1_000_000, i % 2 == 0))
1366            .collect();
1367
1368        let features = compute_intra_bar_features(&trades);
1369        assert_eq!(features.intra_trade_count, Some(10));
1370
1371        // Burstiness requires inter-arrival intervals; with all same timestamps,
1372        // all intervals are 0, std_tau=0, burstiness should be None
1373        if let Some(b) = features.intra_burstiness {
1374            assert!(b.is_finite(), "Burstiness should be finite for instant bar: {}", b);
1375        }
1376
1377        // Intensity with duration=0 should still be finite
1378        if let Some(intensity) = features.intra_intensity {
1379            assert!(intensity.is_finite(), "Intensity should be finite: {}", intensity);
1380        }
1381    }
1382
1383    #[test]
1384    fn test_intra_bar_large_trade_count() {
1385        // 500 trades: stress test for memory and numerical stability
1386        let trades: Vec<AggTrade> = (0..500)
1387            .map(|i| {
1388                let price = 100.0 + (i as f64 * 0.1).sin() * 2.0;
1389                create_test_trade(price, 0.5 + (i as f64 * 0.03).cos(), i * 1_000_000, i % 3 == 0)
1390            })
1391            .collect();
1392
1393        let features = compute_intra_bar_features(&trades);
1394        assert_eq!(features.intra_trade_count, Some(500));
1395
1396        // All bounded features should be valid
1397        if let Some(h) = features.intra_hurst {
1398            assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds at n=500: {}", h);
1399        }
1400        if let Some(pe) = features.intra_permutation_entropy {
1401            assert!(pe >= 0.0 && pe <= 1.0, "PE out of bounds at n=500: {}", pe);
1402        }
1403        if let Some(ofi) = features.intra_ofi {
1404            assert!(ofi >= -1.0 && ofi <= 1.0, "OFI out of bounds at n=500: {}", ofi);
1405        }
1406    }
1407
1408    // === Issue #96: Intra-bar feature boundary and edge case tests ===
1409
1410    #[test]
1411    fn test_intrabar_exactly_2_trades_ith() {
1412        // Minimum threshold for ITH features (n >= 2)
1413        let trades = vec![
1414            create_test_trade(100.0, 1.0, 1_000_000, false),
1415            create_test_trade(100.5, 1.5, 2_000_000, true),
1416        ];
1417        let features = compute_intra_bar_features(&trades);
1418        assert_eq!(features.intra_trade_count, Some(2));
1419
1420        // ITH features should be present for n >= 2
1421        assert!(features.intra_bull_epoch_density.is_some(), "Bull epochs for n=2");
1422        assert!(features.intra_bear_epoch_density.is_some(), "Bear epochs for n=2");
1423        assert!(features.intra_max_drawdown.is_some(), "Max drawdown for n=2");
1424        assert!(features.intra_max_runup.is_some(), "Max runup for n=2");
1425
1426        // Complexity features must be None (need n >= 60/64)
1427        assert!(features.intra_hurst.is_none(), "Hurst requires n >= 64");
1428        assert!(features.intra_permutation_entropy.is_none(), "PE requires n >= 60");
1429
1430        // Kaufman ER for 2-trade straight line should be ~1.0
1431        if let Some(er) = features.intra_kaufman_er {
1432            assert!((er - 1.0).abs() < 0.01, "Straight line ER should be 1.0: {}", er);
1433        }
1434    }
1435
1436    #[test]
1437    fn test_intrabar_pe_boundary_59_vs_60() {
1438        // n=59: below PE threshold → None
1439        let trades_59: Vec<AggTrade> = (0..59)
1440            .map(|i| {
1441                let price = 100.0 + (i as f64 * 0.3).sin() * 2.0;
1442                create_test_trade(price, 1.0, i * 1_000_000, i % 2 == 0)
1443            })
1444            .collect();
1445        let f59 = compute_intra_bar_features(&trades_59);
1446        assert!(f59.intra_permutation_entropy.is_none(), "n=59 should not compute PE");
1447
1448        // n=60: at PE threshold → Some
1449        let trades_60: Vec<AggTrade> = (0..60)
1450            .map(|i| {
1451                let price = 100.0 + (i as f64 * 0.3).sin() * 2.0;
1452                create_test_trade(price, 1.0, i * 1_000_000, i % 2 == 0)
1453            })
1454            .collect();
1455        let f60 = compute_intra_bar_features(&trades_60);
1456        assert!(f60.intra_permutation_entropy.is_some(), "n=60 should compute PE");
1457        let pe60 = f60.intra_permutation_entropy.unwrap();
1458        assert!(pe60.is_finite() && pe60 >= 0.0 && pe60 <= 1.0, "PE(60) out of bounds: {}", pe60);
1459    }
1460
1461    #[test]
1462    fn test_intrabar_hurst_boundary_63_vs_64() {
1463        // n=63: below Hurst threshold → None
1464        let trades_63: Vec<AggTrade> = (0..63)
1465            .map(|i| {
1466                let price = 100.0 + (i as f64 * 0.2).sin() * 2.0;
1467                create_test_trade(price, 1.0, i * 1_000_000, i % 2 == 0)
1468            })
1469            .collect();
1470        let f63 = compute_intra_bar_features(&trades_63);
1471        assert!(f63.intra_hurst.is_none(), "n=63 should not compute Hurst");
1472
1473        // n=64: at Hurst threshold → Some
1474        let trades_64: Vec<AggTrade> = (0..64)
1475            .map(|i| {
1476                let price = 100.0 + (i as f64 * 0.2).sin() * 2.0;
1477                create_test_trade(price, 1.0, i * 1_000_000, i % 2 == 0)
1478            })
1479            .collect();
1480        let f64_features = compute_intra_bar_features(&trades_64);
1481        assert!(f64_features.intra_hurst.is_some(), "n=64 should compute Hurst");
1482        let h64 = f64_features.intra_hurst.unwrap();
1483        assert!(h64.is_finite() && h64 >= 0.0 && h64 <= 1.0, "Hurst(64) out of bounds: {}", h64);
1484    }
1485
1486    #[test]
1487    fn test_intrabar_constant_price_full_features() {
1488        // 100 trades at identical price — tests all features with zero-range input
1489        let trades: Vec<AggTrade> = (0..100)
1490            .map(|i| create_test_trade(42000.0, 1.0, i * 1_000_000, i % 2 == 0))
1491            .collect();
1492        let features = compute_intra_bar_features(&trades);
1493        assert_eq!(features.intra_trade_count, Some(100));
1494
1495        // OFI: equal buy/sell → near 0
1496        if let Some(ofi) = features.intra_ofi {
1497            assert!(ofi.abs() < 0.1, "Equal buy/sell → OFI near 0: {}", ofi);
1498        }
1499
1500        // Garman-Klass: zero price range → 0
1501        if let Some(gk) = features.intra_garman_klass_vol {
1502            assert!(gk.is_finite() && gk < 0.001, "Constant price → GK near 0: {}", gk);
1503        }
1504
1505        // Hurst: flat series → should be finite (may be 0.5 or NaN-clamped)
1506        if let Some(h) = features.intra_hurst {
1507            assert!(h.is_finite() && h >= 0.0 && h <= 1.0, "Hurst must be finite: {}", h);
1508        }
1509
1510        // PE: all identical ordinal patterns → low entropy
1511        if let Some(pe) = features.intra_permutation_entropy {
1512            assert!(pe.is_finite() && pe >= 0.0, "PE must be finite: {}", pe);
1513            assert!(pe < 0.05, "Constant prices → PE near 0: {}", pe);
1514        }
1515
1516        // Kaufman ER: no movement → ER = 1.0 (net = path = 0)
1517        if let Some(er) = features.intra_kaufman_er {
1518            assert!(er.is_finite(), "Kaufman ER finite for constant price: {}", er);
1519        }
1520    }
1521
1522    #[test]
1523    fn test_intrabar_all_buy_with_hurst_pe() {
1524        // 70 buy trades with ascending prices — triggers Hurst + PE computation
1525        let trades: Vec<AggTrade> = (0..70)
1526            .map(|i| create_test_trade(100.0 + i as f64 * 0.1, 1.0, i * 1_000_000, false))
1527            .collect();
1528        let features = compute_intra_bar_features(&trades);
1529
1530        // All buys → OFI = 1.0
1531        if let Some(ofi) = features.intra_ofi {
1532            assert!((ofi - 1.0).abs() < 0.01, "All buys → OFI=1.0: {}", ofi);
1533        }
1534
1535        // Hurst should be computable (n=70 >= 64) and trending
1536        assert!(features.intra_hurst.is_some(), "n=70 should compute Hurst");
1537        if let Some(h) = features.intra_hurst {
1538            assert!(h.is_finite() && h >= 0.0 && h <= 1.0, "Hurst bounded: {}", h);
1539        }
1540
1541        // PE should be computable (n=70 >= 60) and low (monotonic ascending)
1542        assert!(features.intra_permutation_entropy.is_some(), "n=70 should compute PE");
1543        if let Some(pe) = features.intra_permutation_entropy {
1544            assert!(pe.is_finite() && pe >= 0.0 && pe <= 1.0, "PE bounded: {}", pe);
1545            assert!(pe < 0.1, "Monotonic ascending → low PE: {}", pe);
1546        }
1547    }
1548
1549    #[test]
1550    fn test_intrabar_all_sell_with_hurst_pe() {
1551        // 70 sell trades with descending prices — symmetric to all-buy
1552        let trades: Vec<AggTrade> = (0..70)
1553            .map(|i| create_test_trade(100.0 - i as f64 * 0.1, 1.0, i * 1_000_000, true))
1554            .collect();
1555        let features = compute_intra_bar_features(&trades);
1556
1557        // All sells → OFI = -1.0
1558        if let Some(ofi) = features.intra_ofi {
1559            assert!((ofi - (-1.0)).abs() < 0.01, "All sells → OFI=-1.0: {}", ofi);
1560        }
1561
1562        // Hurst and PE should be computable
1563        assert!(features.intra_hurst.is_some(), "n=70 should compute Hurst");
1564        assert!(features.intra_permutation_entropy.is_some(), "n=70 should compute PE");
1565        if let Some(pe) = features.intra_permutation_entropy {
1566            assert!(pe < 0.1, "Monotonic descending → low PE: {}", pe);
1567        }
1568    }
1569
1570    #[test]
1571    fn test_intra_bar_zero_volume_trades() {
1572        // All trades have zero volume: tests division-by-zero handling in
1573        // OFI, VWAP, Kyle Lambda, volume_per_trade, turnover_imbalance
1574        let trades: Vec<AggTrade> = (0..20)
1575            .map(|i| create_test_trade(100.0 + i as f64 * 0.1, 0.0, i * 1_000_000, i % 2 == 0))
1576            .collect();
1577
1578        let features = compute_intra_bar_features(&trades);
1579
1580        // Should not panic — all features must be finite
1581        assert_eq!(features.intra_trade_count, Some(20));
1582
1583        // OFI: (0-0)/0 → guarded to 0.0
1584        if let Some(ofi) = features.intra_ofi {
1585            assert!(ofi.is_finite(), "OFI must be finite with zero volume: {}", ofi);
1586            assert!((ofi).abs() < f64::EPSILON, "OFI should be 0.0 with zero volume: {}", ofi);
1587        }
1588
1589        // VWAP position: zero total_vol → falls back to first_price for vwap
1590        if let Some(vp) = features.intra_vwap_position {
1591            assert!(vp.is_finite(), "VWAP position must be finite: {}", vp);
1592        }
1593
1594        // Kyle Lambda: total_vol=0 → None
1595        assert!(features.intra_kyle_lambda.is_none(), "Kyle Lambda undefined with zero volume");
1596
1597        // Duration and intensity should still be valid
1598        if let Some(d) = features.intra_duration_us {
1599            assert!(d > 0, "Duration should be positive: {}", d);
1600        }
1601        if let Some(intensity) = features.intra_intensity {
1602            assert!(intensity.is_finite() && intensity > 0.0, "Intensity finite: {}", intensity);
1603        }
1604    }
1605}
1606
1607/// Property-based tests for intra-bar feature bounds invariants.
1608/// Uses proptest to verify all features stay within documented ranges
1609/// for arbitrary trade inputs across various market conditions.
1610#[cfg(test)]
1611mod proptest_intrabar_bounds {
1612    use super::*;
1613    use crate::fixed_point::FixedPoint;
1614    use crate::types::AggTrade;
1615    use proptest::prelude::*;
1616
1617    fn make_trade(price: f64, volume: f64, timestamp: i64, is_buyer_maker: bool) -> AggTrade {
1618        AggTrade {
1619            agg_trade_id: timestamp,
1620            price: FixedPoint((price * 1e8) as i64),
1621            volume: FixedPoint((volume * 1e8) as i64),
1622            first_trade_id: timestamp,
1623            last_trade_id: timestamp,
1624            timestamp,
1625            is_buyer_maker,
1626            is_best_match: None,
1627        }
1628    }
1629
1630    /// Strategy: generate a valid trade sequence with varying parameters
1631    fn trade_sequence(min_n: usize, max_n: usize) -> impl Strategy<Value = Vec<AggTrade>> {
1632        (min_n..=max_n, 0_u64..10000).prop_map(|(n, seed)| {
1633            let mut rng = seed;
1634            let base_price = 100.0;
1635            (0..n)
1636                .map(|i| {
1637                    rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
1638                    let r = ((rng >> 33) as f64) / (u32::MAX as f64);
1639                    let price = base_price + (r - 0.5) * 10.0;
1640                    let volume = 0.1 + r * 5.0;
1641                    let ts = (i as i64) * 1_000_000; // 1 second apart
1642                    make_trade(price, volume, ts, rng % 2 == 0)
1643                })
1644                .collect()
1645        })
1646    }
1647
1648    proptest! {
1649        /// All ITH features must be in [0, 1] for any valid trade sequence
1650        #[test]
1651        fn ith_features_always_bounded(trades in trade_sequence(2, 100)) {
1652            let features = compute_intra_bar_features(&trades);
1653
1654            if let Some(v) = features.intra_bull_epoch_density {
1655                prop_assert!(v >= 0.0 && v <= 1.0, "bull_epoch_density={v}");
1656            }
1657            if let Some(v) = features.intra_bear_epoch_density {
1658                prop_assert!(v >= 0.0 && v <= 1.0, "bear_epoch_density={v}");
1659            }
1660            if let Some(v) = features.intra_bull_excess_gain {
1661                prop_assert!(v >= 0.0 && v <= 1.0, "bull_excess_gain={v}");
1662            }
1663            if let Some(v) = features.intra_bear_excess_gain {
1664                prop_assert!(v >= 0.0 && v <= 1.0, "bear_excess_gain={v}");
1665            }
1666            if let Some(v) = features.intra_bull_cv {
1667                prop_assert!(v >= 0.0 && v <= 1.0, "bull_cv={v}");
1668            }
1669            if let Some(v) = features.intra_bear_cv {
1670                prop_assert!(v >= 0.0 && v <= 1.0, "bear_cv={v}");
1671            }
1672            if let Some(v) = features.intra_max_drawdown {
1673                prop_assert!(v >= 0.0 && v <= 1.0, "max_drawdown={v}");
1674            }
1675            if let Some(v) = features.intra_max_runup {
1676                prop_assert!(v >= 0.0 && v <= 1.0, "max_runup={v}");
1677            }
1678        }
1679
1680        /// Statistical features must respect their documented ranges
1681        #[test]
1682        fn statistical_features_bounded(trades in trade_sequence(3, 200)) {
1683            let features = compute_intra_bar_features(&trades);
1684
1685            if let Some(ofi) = features.intra_ofi {
1686                prop_assert!(ofi >= -1.0 - f64::EPSILON && ofi <= 1.0 + f64::EPSILON,
1687                    "OFI={ofi} out of [-1, 1]");
1688            }
1689            if let Some(ci) = features.intra_count_imbalance {
1690                prop_assert!(ci >= -1.0 - f64::EPSILON && ci <= 1.0 + f64::EPSILON,
1691                    "count_imbalance={ci} out of [-1, 1]");
1692            }
1693            if let Some(b) = features.intra_burstiness {
1694                prop_assert!(b >= -1.0 - f64::EPSILON && b <= 1.0 + f64::EPSILON,
1695                    "burstiness={b} out of [-1, 1]");
1696            }
1697            if let Some(er) = features.intra_kaufman_er {
1698                prop_assert!(er >= 0.0 && er <= 1.0 + f64::EPSILON,
1699                    "kaufman_er={er} out of [0, 1]");
1700            }
1701            if let Some(vwap) = features.intra_vwap_position {
1702                prop_assert!(vwap >= 0.0 && vwap <= 1.0 + f64::EPSILON,
1703                    "vwap_position={vwap} out of [0, 1]");
1704            }
1705            if let Some(gk) = features.intra_garman_klass_vol {
1706                prop_assert!(gk >= 0.0, "garman_klass_vol={gk} negative");
1707            }
1708            if let Some(intensity) = features.intra_intensity {
1709                prop_assert!(intensity >= 0.0, "intensity={intensity} negative");
1710            }
1711        }
1712
1713        /// Complexity features (Hurst, PE) bounded when present
1714        #[test]
1715        fn complexity_features_bounded(trades in trade_sequence(70, 300)) {
1716            let features = compute_intra_bar_features(&trades);
1717
1718            if let Some(h) = features.intra_hurst {
1719                prop_assert!(h >= 0.0 && h <= 1.0,
1720                    "hurst={h} out of [0, 1] for n={}", trades.len());
1721            }
1722            if let Some(pe) = features.intra_permutation_entropy {
1723                prop_assert!(pe >= 0.0 && pe <= 1.0 + f64::EPSILON,
1724                    "permutation_entropy={pe} out of [0, 1] for n={}", trades.len());
1725            }
1726        }
1727
1728        /// Trade count always equals input length
1729        #[test]
1730        fn trade_count_matches_input(trades in trade_sequence(0, 50)) {
1731            let features = compute_intra_bar_features(&trades);
1732            prop_assert_eq!(features.intra_trade_count, Some(trades.len() as u32));
1733        }
1734    }
1735}