1use 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#[derive(Debug, Clone)]
29pub struct IntraBarConfig {
30 pub compute_hurst: bool,
32 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
45const MAX_ENTROPY_M3: f64 = 1.791_759_469_228_327;
48
49#[derive(Debug, Clone, Default)]
55pub struct IntraBarFeatures {
56 pub intra_bull_epoch_density: Option<f64>,
59 pub intra_bear_epoch_density: Option<f64>,
61 pub intra_bull_excess_gain: Option<f64>,
63 pub intra_bear_excess_gain: Option<f64>,
65 pub intra_bull_cv: Option<f64>,
67 pub intra_bear_cv: Option<f64>,
69 pub intra_max_drawdown: Option<f64>,
71 pub intra_max_runup: Option<f64>,
73
74 pub intra_trade_count: Option<u32>,
77 pub intra_ofi: Option<f64>,
79 pub intra_duration_us: Option<i64>,
81 pub intra_intensity: Option<f64>,
83 pub intra_vwap_position: Option<f64>,
85 pub intra_count_imbalance: Option<f64>,
87 pub intra_kyle_lambda: Option<f64>,
89 pub intra_burstiness: Option<f64>,
91 pub intra_volume_skew: Option<f64>,
93 pub intra_volume_kurt: Option<f64>,
95 pub intra_kaufman_er: Option<f64>,
97 pub intra_garman_klass_vol: Option<f64>,
99
100 pub intra_hurst: Option<f64>,
103 pub intra_permutation_entropy: Option<f64>,
105}
106
107#[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]
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]
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#[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#[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#[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 if n == 0 {
189 return intra_bar_zero_trades();
190 }
191 if n == 1 {
192 return intra_bar_single_trade();
193 }
194
195 scratch_prices.clear();
197 scratch_prices.reserve(n);
198 for trade in trades {
199 scratch_prices.push(trade.price.to_f64());
200 }
201
202 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 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; let (max_dd, max_ru) = compute_max_drawdown_and_runup(normalized);
219
220 let bull_result = bull_ith(normalized, max_dd);
222
223 let bear_result = bear_ith(normalized, max_ru);
225
226 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 let stats = compute_statistical_features(trades, scratch_prices);
232
233 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 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 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 intra_hurst: hurst,
273 intra_permutation_entropy: pe,
274 }
275}
276
277struct 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
292fn compute_statistical_features(trades: &[AggTrade], prices: &[f64]) -> StatisticalFeatures {
294 let n = trades.len();
295
296 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 for trade in trades {
314 let vol = trade.volume.to_f64(); cached_volumes.push(vol); let price = prices[cached_volumes.len() - 1]; 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 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 let mut m2_vol = 0.0_f64; let mut m3_vol = 0.0_f64; let mut m4_vol = 0.0_f64; for &vol in cached_volumes.iter() {
343 let d = vol - mean_vol;
347 let d2 = d * d;
348 let d3 = d2 * d;
349 let d4 = d2 * d2;
350
351 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 let ofi = if total_vol > f64::EPSILON {
362 (buy_vol - sell_vol) / total_vol
363 } else {
364 0.0
365 };
366
367 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 let duration_sec = duration_us as f64 * 1e-6;
373
374 let intensity = if duration_sec > f64::EPSILON {
376 n as f64 / duration_sec
377 } else {
378 n as f64 };
380
381 let vwap = if total_vol > f64::EPSILON {
383 total_turnover / total_vol
384 } else {
385 prices.first().copied().unwrap_or(0.0)
386 };
387 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 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 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 let burstiness = if n >= 3 {
424 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 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 })
440 .sum::<f64>()
441 * inv_len;
442 let std_tau = variance.sqrt();
443
444 if std_tau <= f64::EPSILON {
446 None } 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 let (volume_skew, volume_kurt) = if n >= 3 {
461 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 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 let kaufman_er = if n >= 2 {
483 let net_move = (prices[n - 1] - prices[0]).abs();
484
485 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) }
496 } else {
497 None
498 };
499
500 const GK_SCALE: f64 = 0.6137; 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 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
532fn compute_hurst_dfa(prices: &[f64]) -> f64 {
541 let n = prices.len();
542 if n < 64 {
543 return 0.5; }
545
546 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 let min_scale = (n / 4).max(8);
558 let max_scale = n / 2;
559
560 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 let x_mean = (scale - 1) as f64 / 2.0;
575 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 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 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 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 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_hurst_lut(hurst)
656}
657
658fn 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; }
673
674 let max_patterns = factorial(m);
677 if max_patterns > 24 {
678 return fallback_permutation_entropy(prices, m);
680 }
681
682 let mut pattern_counts = [0usize; 24]; let num_patterns = n - m + 1;
685
686 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 } else if a <= c { 1 } else { 4 } } else if a <= c { 2 } else if b <= c { 3 } else { 5 }; 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 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 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#[inline]
751fn ordinal_indices_to_pattern_index(indices: &smallvec::SmallVec<[usize; 4]>) -> usize {
752 match indices.len() {
753 2 => {
754 if indices[0] < indices[1] { 0 } else { 1 }
756 }
757 3 => {
758 let mut code = 0usize;
762 let factors = [2, 1, 1];
763
764 let lesser_0 = (indices[1] < indices[0]) as usize + (indices[2] < indices[0]) as usize;
766 code += lesser_0 * factors[0];
767
768 let lesser_1 = (indices[2] < indices[1]) as usize;
770 code += lesser_1 * factors[1];
771
772 code
774 }
775 4 => {
776 let mut code = 0usize;
778 let factors = [6, 2, 1, 1];
779
780 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 let lesser_1 = (indices[2] < indices[1]) as usize
788 + (indices[3] < indices[1]) as usize;
789 code += lesser_1 * factors[1];
790
791 let lesser_2 = (indices[3] < indices[2]) as usize;
793 code += lesser_2 * factors[2];
794
795 code
796 }
797 _ => 0, }
799}
800
801fn 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 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
841fn 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 assert!(features.intra_bull_epoch_density.is_none());
883 }
884
885 #[test]
886 fn test_compute_intra_bar_features_uptrend() {
887 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 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 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 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 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 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 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 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 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 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 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 if let Some(h) = features.intra_hurst {
1043 assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds: {}", h);
1044 }
1045 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 #[test]
1058 fn test_hurst_dfa_all_identical_prices() {
1059 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 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 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 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 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 #[test]
1106 fn test_pe_monotonic_ascending() {
1107 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 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 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 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 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 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 let prices = vec![
1172 1.0, 2.0, 3.0, 1.0, 3.0, 2.0, 2.0, 1.0, 3.0, 2.0, 3.0, 1.0, 2.0, 1.0, 3.0, ];
1178 let pe = compute_permutation_entropy(&prices, 3);
1182 assert!(pe > 0.5, "Sequence with diverse patterns should have high PE: {}", pe);
1183
1184 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 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 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 use smallvec::SmallVec;
1218 let mut seen = std::collections::HashSet::new();
1219 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 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 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 use smallvec::SmallVec;
1264 let p012: SmallVec<[usize; 4]> = SmallVec::from_slice(&[0, 1, 2]);
1266 assert_eq!(ordinal_indices_to_pattern_index(&p012), 0);
1267 let p210: SmallVec<[usize; 4]> = SmallVec::from_slice(&[2, 1, 0]);
1269 assert_eq!(ordinal_indices_to_pattern_index(&p210), 5);
1270 let p102: SmallVec<[usize; 4]> = SmallVec::from_slice(&[1, 0, 2]);
1272 assert_eq!(ordinal_indices_to_pattern_index(&p102), 2);
1273 }
1274
1275 #[test]
1278 fn test_intra_bar_nan_first_price() {
1279 let trades = vec![
1281 AggTrade {
1282 agg_trade_id: 1,
1283 price: FixedPoint(0), 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 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 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 if let Some(er) = features.intra_kaufman_er {
1312 assert!(er.is_finite(), "Kaufman ER should be finite: {}", er);
1314 }
1315
1316 if let Some(gk) = features.intra_garman_klass_vol {
1318 assert!(gk.is_finite(), "Garman-Klass should be finite: {}", gk);
1319 }
1320
1321 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 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 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 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 if let Some(b) = features.intra_burstiness {
1374 assert!(b.is_finite(), "Burstiness should be finite for instant bar: {}", b);
1375 }
1376
1377 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 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 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 #[test]
1411 fn test_intrabar_exactly_2_trades_ith() {
1412 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 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 assert!(features.intra_hurst.is_none(), "Hurst requires n >= 64");
1428 assert!(features.intra_permutation_entropy.is_none(), "PE requires n >= 60");
1429
1430 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 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 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 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 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 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 if let Some(ofi) = features.intra_ofi {
1497 assert!(ofi.abs() < 0.1, "Equal buy/sell → OFI near 0: {}", ofi);
1498 }
1499
1500 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 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 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 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 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 if let Some(ofi) = features.intra_ofi {
1532 assert!((ofi - 1.0).abs() < 0.01, "All buys → OFI=1.0: {}", ofi);
1533 }
1534
1535 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 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 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 if let Some(ofi) = features.intra_ofi {
1559 assert!((ofi - (-1.0)).abs() < 0.01, "All sells → OFI=-1.0: {}", ofi);
1560 }
1561
1562 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 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 assert_eq!(features.intra_trade_count, Some(20));
1582
1583 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 if let Some(vp) = features.intra_vwap_position {
1591 assert!(vp.is_finite(), "VWAP position must be finite: {}", vp);
1592 }
1593
1594 assert!(features.intra_kyle_lambda.is_none(), "Kyle Lambda undefined with zero volume");
1596
1597 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#[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 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; make_trade(price, volume, ts, rng % 2 == 0)
1643 })
1644 .collect()
1645 })
1646 }
1647
1648 proptest! {
1649 #[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 #[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 #[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 #[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}