Skip to main content

rangebar_core/intrabar/
features.rs

1//! Intra-bar feature computation from constituent trades.
2//!
3//! Issue #59: Intra-bar microstructure features for large range bars.
4//!
5//! This module computes 22 features from trades WITHIN each range bar:
6//! - 8 ITH features (from trading-fitness algorithms)
7//! - 12 statistical features
8//! - 2 complexity features (Hurst, Permutation Entropy)
9
10use crate::types::AggTrade;
11
12use super::drawdown::{compute_max_drawdown, compute_max_runup};
13use super::ith::{bear_ith, bull_ith};
14use super::normalize::{
15    normalize_cv, normalize_drawdown, normalize_epochs, normalize_excess, normalize_runup,
16};
17
18/// All 22 intra-bar features computed from constituent trades.
19///
20/// All ITH-based features are normalized to [0, 1] for LSTM consumption.
21/// Statistical features preserve their natural ranges.
22/// Optional fields return None when insufficient data.
23#[derive(Debug, Clone, Default)]
24pub struct IntraBarFeatures {
25    // === ITH Features (8) - All bounded [0, 1] ===
26    /// Bull epoch density: sigmoid(epochs/trade_count, 0.5, 10)
27    pub intra_bull_epoch_density: Option<f64>,
28    /// Bear epoch density: sigmoid(epochs/trade_count, 0.5, 10)
29    pub intra_bear_epoch_density: Option<f64>,
30    /// Bull excess gain (sum): tanh-normalized to [0, 1]
31    pub intra_bull_excess_gain: Option<f64>,
32    /// Bear excess gain (sum): tanh-normalized to [0, 1]
33    pub intra_bear_excess_gain: Option<f64>,
34    /// Bull intervals CV: sigmoid-normalized to [0, 1]
35    pub intra_bull_cv: Option<f64>,
36    /// Bear intervals CV: sigmoid-normalized to [0, 1]
37    pub intra_bear_cv: Option<f64>,
38    /// Max drawdown in bar: already [0, 1]
39    pub intra_max_drawdown: Option<f64>,
40    /// Max runup in bar: already [0, 1]
41    pub intra_max_runup: Option<f64>,
42
43    // === Statistical Features (12) ===
44    /// Number of trades in the bar
45    pub intra_trade_count: Option<u32>,
46    /// Order Flow Imbalance: (buy_vol - sell_vol) / total_vol, [-1, 1]
47    pub intra_ofi: Option<f64>,
48    /// Duration of bar in microseconds
49    pub intra_duration_us: Option<i64>,
50    /// Trade intensity: trades per second
51    pub intra_intensity: Option<f64>,
52    /// VWAP position within price range: [0, 1]
53    pub intra_vwap_position: Option<f64>,
54    /// Count imbalance: (buy_count - sell_count) / total_count, [-1, 1]
55    pub intra_count_imbalance: Option<f64>,
56    /// Kyle's Lambda proxy (normalized)
57    pub intra_kyle_lambda: Option<f64>,
58    /// Burstiness (Goh-Barabási): [-1, 1]
59    pub intra_burstiness: Option<f64>,
60    /// Volume skewness
61    pub intra_volume_skew: Option<f64>,
62    /// Volume excess kurtosis
63    pub intra_volume_kurt: Option<f64>,
64    /// Kaufman Efficiency Ratio: [0, 1]
65    pub intra_kaufman_er: Option<f64>,
66    /// Garman-Klass volatility estimator
67    pub intra_garman_klass_vol: Option<f64>,
68
69    // === Complexity Features (2) - Require many trades ===
70    /// Hurst exponent via DFA (requires >= 64 trades)
71    pub intra_hurst: Option<f64>,
72    /// Permutation entropy (requires >= 60 trades)
73    pub intra_permutation_entropy: Option<f64>,
74}
75
76/// Compute all intra-bar features from constituent trades.
77///
78/// This is the main entry point for computing ITH and statistical features
79/// from the trades that formed a range bar.
80///
81/// # Arguments
82/// * `trades` - Slice of AggTrade records within the bar
83///
84/// # Returns
85/// `IntraBarFeatures` struct with all 22 features (or None for insufficient data)
86pub fn compute_intra_bar_features(trades: &[AggTrade]) -> IntraBarFeatures {
87    let n = trades.len();
88
89    if n < 2 {
90        return IntraBarFeatures {
91            intra_trade_count: Some(n as u32),
92            ..Default::default()
93        };
94    }
95
96    // Extract price series from trades
97    let prices: Vec<f64> = trades.iter().map(|t| t.price.to_f64()).collect();
98
99    // Normalize prices to start at 1.0 for ITH computation
100    let first_price = prices[0];
101    if first_price <= 0.0 || !first_price.is_finite() {
102        return IntraBarFeatures {
103            intra_trade_count: Some(n as u32),
104            ..Default::default()
105        };
106    }
107    let normalized: Vec<f64> = prices.iter().map(|p| p / first_price).collect();
108
109    // Compute max_drawdown and max_runup (used as TMAEG - no magic numbers)
110    let max_dd = compute_max_drawdown(&normalized);
111    let max_ru = compute_max_runup(&normalized);
112
113    // Compute Bull ITH with max_drawdown as TMAEG
114    let bull_result = bull_ith(&normalized, max_dd);
115
116    // Compute Bear ITH with max_runup as TMAEG
117    let bear_result = bear_ith(&normalized, max_ru);
118
119    // Sum excess gains for normalization
120    let bull_excess_sum: f64 = bull_result.excess_gains.iter().sum();
121    let bear_excess_sum: f64 = bear_result.excess_gains.iter().sum();
122
123    // Compute statistical features
124    let stats = compute_statistical_features(trades, &prices);
125
126    // Compute complexity features (only if enough trades)
127    let hurst = if n >= 64 {
128        Some(compute_hurst_dfa(&normalized))
129    } else {
130        None
131    };
132    let pe = if n >= 60 {
133        Some(compute_permutation_entropy(&prices, 3))
134    } else {
135        None
136    };
137
138    IntraBarFeatures {
139        // ITH features (normalized to [0, 1])
140        intra_bull_epoch_density: Some(normalize_epochs(bull_result.num_of_epochs, n)),
141        intra_bear_epoch_density: Some(normalize_epochs(bear_result.num_of_epochs, n)),
142        intra_bull_excess_gain: Some(normalize_excess(bull_excess_sum)),
143        intra_bear_excess_gain: Some(normalize_excess(bear_excess_sum)),
144        intra_bull_cv: Some(normalize_cv(bull_result.intervals_cv)),
145        intra_bear_cv: Some(normalize_cv(bear_result.intervals_cv)),
146        intra_max_drawdown: Some(normalize_drawdown(bull_result.max_drawdown)),
147        intra_max_runup: Some(normalize_runup(bear_result.max_runup)),
148
149        // Statistical features
150        intra_trade_count: Some(n as u32),
151        intra_ofi: Some(stats.ofi),
152        intra_duration_us: Some(stats.duration_us),
153        intra_intensity: Some(stats.intensity),
154        intra_vwap_position: Some(stats.vwap_position),
155        intra_count_imbalance: Some(stats.count_imbalance),
156        intra_kyle_lambda: stats.kyle_lambda,
157        intra_burstiness: stats.burstiness,
158        intra_volume_skew: stats.volume_skew,
159        intra_volume_kurt: stats.volume_kurt,
160        intra_kaufman_er: stats.kaufman_er,
161        intra_garman_klass_vol: Some(stats.garman_klass_vol),
162
163        // Complexity features
164        intra_hurst: hurst,
165        intra_permutation_entropy: pe,
166    }
167}
168
169/// Intermediate struct for statistical features computation
170struct StatisticalFeatures {
171    ofi: f64,
172    duration_us: i64,
173    intensity: f64,
174    vwap_position: f64,
175    count_imbalance: f64,
176    kyle_lambda: Option<f64>,
177    burstiness: Option<f64>,
178    volume_skew: Option<f64>,
179    volume_kurt: Option<f64>,
180    kaufman_er: Option<f64>,
181    garman_klass_vol: f64,
182}
183
184/// Compute statistical features from trades
185fn compute_statistical_features(trades: &[AggTrade], prices: &[f64]) -> StatisticalFeatures {
186    let n = trades.len();
187
188    // Volume aggregation
189    let mut buy_vol = 0.0_f64;
190    let mut sell_vol = 0.0_f64;
191    let mut buy_count = 0_u32;
192    let mut sell_count = 0_u32;
193    let mut total_turnover = 0.0_f64;
194    let volumes: Vec<f64> = trades.iter().map(|t| t.volume.to_f64()).collect();
195
196    for trade in trades {
197        let vol = trade.volume.to_f64();
198        let price = trade.price.to_f64();
199        total_turnover += price * vol;
200
201        if trade.is_buyer_maker {
202            sell_vol += vol;
203            sell_count += trade.individual_trade_count() as u32;
204        } else {
205            buy_vol += vol;
206            buy_count += trade.individual_trade_count() as u32;
207        }
208    }
209
210    let total_vol = buy_vol + sell_vol;
211    let total_count = (buy_count + sell_count) as f64;
212
213    // OFI: Order Flow Imbalance
214    let ofi = if total_vol > f64::EPSILON {
215        (buy_vol - sell_vol) / total_vol
216    } else {
217        0.0
218    };
219
220    // Duration
221    let first_ts = trades.first().map(|t| t.timestamp).unwrap_or(0);
222    let last_ts = trades.last().map(|t| t.timestamp).unwrap_or(0);
223    let duration_us = last_ts - first_ts;
224    let duration_sec = duration_us as f64 / 1_000_000.0;
225
226    // Intensity: trades per second
227    let intensity = if duration_sec > f64::EPSILON {
228        n as f64 / duration_sec
229    } else {
230        n as f64 // Instant bar
231    };
232
233    // VWAP position
234    let vwap = if total_vol > f64::EPSILON {
235        total_turnover / total_vol
236    } else {
237        prices.first().copied().unwrap_or(0.0)
238    };
239    let high = prices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
240    let low = prices.iter().cloned().fold(f64::INFINITY, f64::min);
241    let range = high - low;
242    let vwap_position = if range > f64::EPSILON {
243        ((vwap - low) / range).clamp(0.0, 1.0)
244    } else {
245        0.5
246    };
247
248    // Count imbalance
249    let count_imbalance = if total_count > f64::EPSILON {
250        (buy_count as f64 - sell_count as f64) / total_count
251    } else {
252        0.0
253    };
254
255    // Kyle's Lambda (requires >= 2 trades)
256    let kyle_lambda = if n >= 2 && total_vol > f64::EPSILON {
257        let first_price = prices[0];
258        let last_price = prices[n - 1];
259        let price_return = if first_price.abs() > f64::EPSILON {
260            (last_price - first_price) / first_price
261        } else {
262            0.0
263        };
264        let normalized_imbalance = (buy_vol - sell_vol) / total_vol;
265        if normalized_imbalance.abs() > f64::EPSILON {
266            Some(price_return / normalized_imbalance)
267        } else {
268            None
269        }
270    } else {
271        None
272    };
273
274    // Burstiness (requires >= 2 trades for inter-arrival times)
275    let burstiness = if n >= 2 {
276        let timestamps: Vec<i64> = trades.iter().map(|t| t.timestamp).collect();
277        let intervals: Vec<f64> = timestamps
278            .windows(2)
279            .map(|w| (w[1] - w[0]) as f64)
280            .collect();
281
282        if !intervals.is_empty() {
283            let mean_tau: f64 = intervals.iter().sum::<f64>() / intervals.len() as f64;
284            let variance: f64 = intervals
285                .iter()
286                .map(|&x| (x - mean_tau).powi(2))
287                .sum::<f64>()
288                / intervals.len() as f64;
289            let std_tau = variance.sqrt();
290
291            if (std_tau + mean_tau).abs() > f64::EPSILON {
292                Some((std_tau - mean_tau) / (std_tau + mean_tau))
293            } else {
294                None
295            }
296        } else {
297            None
298        }
299    } else {
300        None
301    };
302
303    // Volume skewness (requires >= 3 trades)
304    let volume_skew = if n >= 3 {
305        let mean_v: f64 = volumes.iter().sum::<f64>() / n as f64;
306        let variance: f64 = volumes.iter().map(|&v| (v - mean_v).powi(2)).sum::<f64>() / n as f64;
307        let std_v = variance.sqrt();
308
309        if std_v > f64::EPSILON {
310            let m3: f64 = volumes.iter().map(|&v| (v - mean_v).powi(3)).sum::<f64>() / n as f64;
311            Some(m3 / std_v.powi(3))
312        } else {
313            None
314        }
315    } else {
316        None
317    };
318
319    // Volume kurtosis (requires >= 4 trades)
320    let volume_kurt = if n >= 4 {
321        let mean_v: f64 = volumes.iter().sum::<f64>() / n as f64;
322        let variance: f64 = volumes.iter().map(|&v| (v - mean_v).powi(2)).sum::<f64>() / n as f64;
323        let std_v = variance.sqrt();
324
325        if std_v > f64::EPSILON {
326            let m4: f64 = volumes.iter().map(|&v| (v - mean_v).powi(4)).sum::<f64>() / n as f64;
327            Some(m4 / std_v.powi(4) - 3.0) // Excess kurtosis
328        } else {
329            None
330        }
331    } else {
332        None
333    };
334
335    // Kaufman Efficiency Ratio (requires >= 2 trades)
336    let kaufman_er = if n >= 2 {
337        let net_move = (prices[n - 1] - prices[0]).abs();
338        let path_length: f64 = prices.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
339
340        if path_length > f64::EPSILON {
341            Some((net_move / path_length).clamp(0.0, 1.0))
342        } else {
343            Some(1.0) // No movement = perfectly efficient
344        }
345    } else {
346        None
347    };
348
349    // Garman-Klass volatility
350    let open = prices[0];
351    let close = prices[n - 1];
352    let garman_klass_vol = if high > low && high > 0.0 && open > 0.0 {
353        let hl_ratio = (high / low).ln();
354        let co_ratio = (close / open).ln();
355        let gk_var = 0.5 * hl_ratio.powi(2) - (2.0 * 2.0_f64.ln() - 1.0) * co_ratio.powi(2);
356        gk_var.max(0.0).sqrt()
357    } else {
358        0.0
359    };
360
361    StatisticalFeatures {
362        ofi,
363        duration_us,
364        intensity,
365        vwap_position,
366        count_imbalance,
367        kyle_lambda,
368        burstiness,
369        volume_skew,
370        volume_kurt,
371        kaufman_er,
372        garman_klass_vol,
373    }
374}
375
376/// Compute Hurst exponent via Detrended Fluctuation Analysis (DFA).
377///
378/// The Hurst exponent measures long-term memory:
379/// - H < 0.5: Mean-reverting (anti-persistent)
380/// - H = 0.5: Random walk
381/// - H > 0.5: Trending (persistent)
382///
383/// Requires at least 64 observations for reliable estimation.
384fn compute_hurst_dfa(prices: &[f64]) -> f64 {
385    let n = prices.len();
386    if n < 64 {
387        return 0.5; // Default to random walk for insufficient data
388    }
389
390    // Compute cumulative deviation from mean
391    let mean: f64 = prices.iter().sum::<f64>() / n as f64;
392    let y: Vec<f64> = prices
393        .iter()
394        .scan(0.0, |acc, &p| {
395            *acc += p - mean;
396            Some(*acc)
397        })
398        .collect();
399
400    // Scale range from n/4 to n/2 (using powers of 2 for efficiency)
401    let min_scale = (n / 4).max(8);
402    let max_scale = n / 2;
403
404    let mut log_scales = Vec::new();
405    let mut log_fluctuations = Vec::new();
406
407    let mut scale = min_scale;
408    while scale <= max_scale {
409        let num_segments = n / scale;
410        if num_segments < 2 {
411            break;
412        }
413
414        let mut total_fluctuation = 0.0;
415        let mut segment_count = 0;
416
417        for seg in 0..num_segments {
418            let start = seg * scale;
419            let end = start + scale;
420            if end > n {
421                break;
422            }
423
424            // Linear detrend via least squares
425            let x_mean = (scale - 1) as f64 / 2.0;
426            let mut xy_sum = 0.0;
427            let mut xx_sum = 0.0;
428            let mut y_sum = 0.0;
429
430            for (i, &yi) in y[start..end].iter().enumerate() {
431                let xi = i as f64;
432                xy_sum += (xi - x_mean) * yi;
433                xx_sum += (xi - x_mean).powi(2);
434                y_sum += yi;
435            }
436
437            let y_mean = y_sum / scale as f64;
438            let slope = if xx_sum.abs() > f64::EPSILON {
439                xy_sum / xx_sum
440            } else {
441                0.0
442            };
443
444            // Compute RMS of detrended segment
445            let mut rms = 0.0;
446            for (i, &yi) in y[start..end].iter().enumerate() {
447                let trend = y_mean + slope * (i as f64 - x_mean);
448                rms += (yi - trend).powi(2);
449            }
450            rms = (rms / scale as f64).sqrt();
451
452            total_fluctuation += rms;
453            segment_count += 1;
454        }
455
456        if segment_count > 0 {
457            let avg_fluctuation = total_fluctuation / segment_count as f64;
458            if avg_fluctuation > f64::EPSILON {
459                log_scales.push((scale as f64).ln());
460                log_fluctuations.push(avg_fluctuation.ln());
461            }
462        }
463
464        scale = (scale as f64 * 1.5).ceil() as usize;
465    }
466
467    // Linear regression for Hurst exponent
468    if log_scales.len() < 2 {
469        return 0.5;
470    }
471
472    let n_points = log_scales.len() as f64;
473    let x_mean: f64 = log_scales.iter().sum::<f64>() / n_points;
474    let y_mean: f64 = log_fluctuations.iter().sum::<f64>() / n_points;
475
476    let mut xy_sum = 0.0;
477    let mut xx_sum = 0.0;
478    for (&x, &y) in log_scales.iter().zip(log_fluctuations.iter()) {
479        xy_sum += (x - x_mean) * (y - y_mean);
480        xx_sum += (x - x_mean).powi(2);
481    }
482
483    let hurst = if xx_sum.abs() > f64::EPSILON {
484        xy_sum / xx_sum
485    } else {
486        0.5
487    };
488
489    // Soft-clamp to [0, 1] using sigmoid
490    1.0 / (1.0 + (-4.0 * (hurst - 0.5)).exp())
491}
492
493/// Compute normalized permutation entropy.
494///
495/// Permutation entropy measures the complexity of a time series
496/// by analyzing ordinal patterns. Returns value in [0, 1].
497///
498/// Requires at least `m! + (m-1)` observations where m is the embedding dimension.
499fn compute_permutation_entropy(prices: &[f64], m: usize) -> f64 {
500    let n = prices.len();
501    let required = factorial(m) + m - 1;
502
503    if n < required || m < 2 {
504        return 0.5; // Default for insufficient data
505    }
506
507    // Count ordinal patterns
508    let mut pattern_counts = std::collections::HashMap::new();
509    let num_patterns = n - m + 1;
510
511    for i in 0..num_patterns {
512        let window = &prices[i..i + m];
513
514        // Create sorted indices (ordinal pattern)
515        let mut indices: Vec<usize> = (0..m).collect();
516        indices.sort_by(|&a, &b| {
517            window[a]
518                .partial_cmp(&window[b])
519                .unwrap_or(std::cmp::Ordering::Equal)
520        });
521
522        // Convert to pattern key
523        let pattern_key: String = indices.iter().map(|&i| i.to_string()).collect();
524        *pattern_counts.entry(pattern_key).or_insert(0usize) += 1;
525    }
526
527    // Compute Shannon entropy
528    let mut entropy = 0.0;
529    for &count in pattern_counts.values() {
530        if count > 0 {
531            let p = count as f64 / num_patterns as f64;
532            entropy -= p * p.ln();
533        }
534    }
535
536    // Normalize by maximum entropy (log(m!))
537    let max_entropy = (factorial(m) as f64).ln();
538    if max_entropy > f64::EPSILON {
539        (entropy / max_entropy).clamp(0.0, 1.0)
540    } else {
541        0.5
542    }
543}
544
545/// Factorial function for small integers
546fn factorial(n: usize) -> usize {
547    (1..=n).product()
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::fixed_point::FixedPoint;
554
555    fn create_test_trade(
556        price: f64,
557        volume: f64,
558        timestamp: i64,
559        is_buyer_maker: bool,
560    ) -> AggTrade {
561        AggTrade {
562            agg_trade_id: timestamp,
563            price: FixedPoint((price * 1e8) as i64),
564            volume: FixedPoint((volume * 1e8) as i64),
565            first_trade_id: timestamp,
566            last_trade_id: timestamp,
567            timestamp,
568            is_buyer_maker,
569            is_best_match: None,
570        }
571    }
572
573    #[test]
574    fn test_compute_intra_bar_features_empty() {
575        let features = compute_intra_bar_features(&[]);
576        assert_eq!(features.intra_trade_count, Some(0));
577        assert!(features.intra_bull_epoch_density.is_none());
578    }
579
580    #[test]
581    fn test_compute_intra_bar_features_single_trade() {
582        let trades = vec![create_test_trade(100.0, 1.0, 1000000, false)];
583        let features = compute_intra_bar_features(&trades);
584        assert_eq!(features.intra_trade_count, Some(1));
585        // Most features require >= 2 trades
586        assert!(features.intra_bull_epoch_density.is_none());
587    }
588
589    #[test]
590    fn test_compute_intra_bar_features_uptrend() {
591        // Create uptrending price series
592        let trades: Vec<AggTrade> = (0..10)
593            .map(|i| create_test_trade(100.0 + i as f64 * 0.5, 1.0, i * 1000000, false))
594            .collect();
595
596        let features = compute_intra_bar_features(&trades);
597
598        assert_eq!(features.intra_trade_count, Some(10));
599        assert!(features.intra_bull_epoch_density.is_some());
600        assert!(features.intra_bear_epoch_density.is_some());
601
602        // In uptrend, max_drawdown should be low
603        if let Some(dd) = features.intra_max_drawdown {
604            assert!(dd < 0.1, "Uptrend should have low drawdown: {}", dd);
605        }
606    }
607
608    #[test]
609    fn test_compute_intra_bar_features_downtrend() {
610        // Create downtrending price series
611        let trades: Vec<AggTrade> = (0..10)
612            .map(|i| create_test_trade(100.0 - i as f64 * 0.5, 1.0, i * 1000000, true))
613            .collect();
614
615        let features = compute_intra_bar_features(&trades);
616
617        assert_eq!(features.intra_trade_count, Some(10));
618
619        // In downtrend, max_runup should be low
620        if let Some(ru) = features.intra_max_runup {
621            assert!(ru < 0.1, "Downtrend should have low runup: {}", ru);
622        }
623    }
624
625    #[test]
626    fn test_ofi_calculation() {
627        // All buys
628        let buy_trades: Vec<AggTrade> = (0..5)
629            .map(|i| create_test_trade(100.0, 1.0, i * 1000000, false))
630            .collect();
631
632        let features = compute_intra_bar_features(&buy_trades);
633        assert!(
634            features.intra_ofi.unwrap() > 0.9,
635            "All buys should have OFI near 1.0"
636        );
637
638        // All sells
639        let sell_trades: Vec<AggTrade> = (0..5)
640            .map(|i| create_test_trade(100.0, 1.0, i * 1000000, true))
641            .collect();
642
643        let features = compute_intra_bar_features(&sell_trades);
644        assert!(
645            features.intra_ofi.unwrap() < -0.9,
646            "All sells should have OFI near -1.0"
647        );
648    }
649
650    #[test]
651    fn test_ith_features_bounded() {
652        // Generate random-ish price series
653        let trades: Vec<AggTrade> = (0..50)
654            .map(|i| {
655                let price = 100.0 + ((i as f64 * 0.7).sin() * 2.0);
656                create_test_trade(price, 1.0, i * 1000000, i % 2 == 0)
657            })
658            .collect();
659
660        let features = compute_intra_bar_features(&trades);
661
662        // All ITH features should be bounded [0, 1]
663        if let Some(v) = features.intra_bull_epoch_density {
664            assert!(
665                v >= 0.0 && v <= 1.0,
666                "bull_epoch_density out of bounds: {}",
667                v
668            );
669        }
670        if let Some(v) = features.intra_bear_epoch_density {
671            assert!(
672                v >= 0.0 && v <= 1.0,
673                "bear_epoch_density out of bounds: {}",
674                v
675            );
676        }
677        if let Some(v) = features.intra_bull_excess_gain {
678            assert!(
679                v >= 0.0 && v <= 1.0,
680                "bull_excess_gain out of bounds: {}",
681                v
682            );
683        }
684        if let Some(v) = features.intra_bear_excess_gain {
685            assert!(
686                v >= 0.0 && v <= 1.0,
687                "bear_excess_gain out of bounds: {}",
688                v
689            );
690        }
691        if let Some(v) = features.intra_bull_cv {
692            assert!(v >= 0.0 && v <= 1.0, "bull_cv out of bounds: {}", v);
693        }
694        if let Some(v) = features.intra_bear_cv {
695            assert!(v >= 0.0 && v <= 1.0, "bear_cv out of bounds: {}", v);
696        }
697        if let Some(v) = features.intra_max_drawdown {
698            assert!(v >= 0.0 && v <= 1.0, "max_drawdown out of bounds: {}", v);
699        }
700        if let Some(v) = features.intra_max_runup {
701            assert!(v >= 0.0 && v <= 1.0, "max_runup out of bounds: {}", v);
702        }
703    }
704
705    #[test]
706    fn test_kaufman_er_bounds() {
707        // Perfectly efficient (straight line)
708        let efficient_trades: Vec<AggTrade> = (0..10)
709            .map(|i| create_test_trade(100.0 + i as f64, 1.0, i * 1000000, false))
710            .collect();
711
712        let features = compute_intra_bar_features(&efficient_trades);
713        if let Some(er) = features.intra_kaufman_er {
714            assert!(
715                (er - 1.0).abs() < 0.01,
716                "Straight line should have ER near 1.0: {}",
717                er
718            );
719        }
720    }
721
722    #[test]
723    fn test_complexity_features_require_data() {
724        // Less than 60 trades - complexity features should be None
725        let small_trades: Vec<AggTrade> = (0..30)
726            .map(|i| create_test_trade(100.0, 1.0, i * 1000000, false))
727            .collect();
728
729        let features = compute_intra_bar_features(&small_trades);
730        assert!(features.intra_hurst.is_none());
731        assert!(features.intra_permutation_entropy.is_none());
732
733        // 65+ trades - complexity features should be Some
734        let large_trades: Vec<AggTrade> = (0..70)
735            .map(|i| {
736                let price = 100.0 + ((i as f64 * 0.1).sin() * 2.0);
737                create_test_trade(price, 1.0, i * 1000000, false)
738            })
739            .collect();
740
741        let features = compute_intra_bar_features(&large_trades);
742        assert!(features.intra_hurst.is_some());
743        assert!(features.intra_permutation_entropy.is_some());
744
745        // Hurst should be bounded [0, 1]
746        if let Some(h) = features.intra_hurst {
747            assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds: {}", h);
748        }
749        // Permutation entropy should be bounded [0, 1]
750        if let Some(pe) = features.intra_permutation_entropy {
751            assert!(
752                pe >= 0.0 && pe <= 1.0,
753                "Permutation entropy out of bounds: {}",
754                pe
755            );
756        }
757    }
758}