1use crate::fixed_point::FixedPoint;
34use crate::interbar_math::*;
35use crate::types::AggTrade;
36use std::collections::VecDeque;
37
38pub use crate::interbar_types::{InterBarConfig, InterBarFeatures, LookbackMode, TradeSnapshot};
40
41#[derive(Debug, Clone)]
43pub struct TradeHistory {
44 trades: VecDeque<TradeSnapshot>,
46 config: InterBarConfig,
48 protected_until: Option<i64>,
52 total_pushed: usize,
54 bar_close_indices: VecDeque<usize>,
58}
59
60impl TradeHistory {
61 pub fn new(config: InterBarConfig) -> Self {
63 let capacity = match &config.lookback_mode {
64 LookbackMode::FixedCount(n) => *n * 2, LookbackMode::FixedWindow(_) | LookbackMode::BarRelative(_) => 2000, };
67 Self {
68 trades: VecDeque::with_capacity(capacity),
69 config,
70 protected_until: None,
71 total_pushed: 0,
72 bar_close_indices: VecDeque::new(),
73 }
74 }
75
76 pub fn push(&mut self, trade: &AggTrade) {
81 let snapshot = TradeSnapshot::from(trade);
82 self.trades.push_back(snapshot);
83 self.total_pushed += 1;
84 self.prune();
85 }
86
87 pub fn on_bar_open(&mut self, bar_open_time: i64) {
93 self.protected_until = Some(bar_open_time);
96 }
97
98 pub fn on_bar_close(&mut self) {
104 if let LookbackMode::BarRelative(n_bars) = &self.config.lookback_mode {
106 self.bar_close_indices.push_back(self.total_pushed);
107 while self.bar_close_indices.len() > *n_bars + 1 {
109 self.bar_close_indices.pop_front();
110 }
111 }
112 }
114
115 fn prune(&mut self) {
123 match &self.config.lookback_mode {
124 LookbackMode::FixedCount(n) => {
125 let max_trades = *n * 2;
127 while self.trades.len() > max_trades {
128 if let Some(front) = self.trades.front() {
130 if let Some(protected) = self.protected_until {
131 if front.timestamp < protected {
132 break;
134 }
135 }
136 }
137 self.trades.pop_front();
138 }
139 }
140 LookbackMode::FixedWindow(window_us) => {
141 let newest_timestamp = self.trades.back().map(|t| t.timestamp).unwrap_or(0);
143 let cutoff = newest_timestamp - window_us;
144
145 while let Some(front) = self.trades.front() {
146 if let Some(protected) = self.protected_until {
148 if front.timestamp < protected {
149 break;
150 }
151 }
152 if front.timestamp < cutoff {
154 self.trades.pop_front();
155 } else {
156 break;
157 }
158 }
159 }
160 LookbackMode::BarRelative(n_bars) => {
161 if self.bar_close_indices.len() <= *n_bars {
176 return;
179 }
180
181 let oldest_boundary = self.bar_close_indices.front().copied().unwrap_or(0);
185 let keep_count = self.total_pushed - oldest_boundary;
186
187 while self.trades.len() > keep_count {
189 self.trades.pop_front();
190 }
191 }
192 }
193 }
194
195 pub fn get_lookback_trades(&self, bar_open_time: i64) -> Vec<&TradeSnapshot> {
200 self.trades
201 .iter()
202 .filter(|t| t.timestamp < bar_open_time)
203 .collect()
204 }
205
206 pub fn compute_features(&self, bar_open_time: i64) -> InterBarFeatures {
217 let lookback: Vec<&TradeSnapshot> = self.get_lookback_trades(bar_open_time);
218
219 if lookback.is_empty() {
220 return InterBarFeatures::default();
221 }
222
223 let mut features = InterBarFeatures::default();
224
225 self.compute_tier1_features(&lookback, &mut features);
227
228 if self.config.compute_tier2 {
230 self.compute_tier2_features(&lookback, &mut features);
231 }
232
233 if self.config.compute_tier3 {
235 self.compute_tier3_features(&lookback, &mut features);
236 }
237
238 features
239 }
240
241 fn compute_tier1_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
243 let n = lookback.len();
244 if n == 0 {
245 return;
246 }
247
248 features.lookback_trade_count = Some(n as u32);
250
251 let (buy_vol, sell_vol, buy_count, sell_count, total_turnover) =
253 lookback
254 .iter()
255 .fold((0.0, 0.0, 0u32, 0u32, 0i128), |acc, t| {
256 if t.is_buyer_maker {
257 (
259 acc.0,
260 acc.1 + t.volume.to_f64(),
261 acc.2,
262 acc.3 + 1,
263 acc.4 + t.turnover,
264 )
265 } else {
266 (
268 acc.0 + t.volume.to_f64(),
269 acc.1,
270 acc.2 + 1,
271 acc.3,
272 acc.4 + t.turnover,
273 )
274 }
275 });
276
277 let total_vol = buy_vol + sell_vol;
278
279 features.lookback_ofi = Some(if total_vol > f64::EPSILON {
281 (buy_vol - sell_vol) / total_vol
282 } else {
283 0.0
284 });
285
286 let total_count = buy_count + sell_count;
288 features.lookback_count_imbalance = Some(if total_count > 0 {
289 (buy_count as f64 - sell_count as f64) / total_count as f64
290 } else {
291 0.0
292 });
293
294 let first_ts = lookback.first().unwrap().timestamp;
296 let last_ts = lookback.last().unwrap().timestamp;
297 let duration_us = last_ts - first_ts;
298 features.lookback_duration_us = Some(duration_us);
299
300 let duration_sec = duration_us as f64 / 1_000_000.0;
302 features.lookback_intensity = Some(if duration_sec > f64::EPSILON {
303 n as f64 / duration_sec
304 } else {
305 n as f64 });
307
308 let total_volume_fp: i128 = lookback.iter().map(|t| t.volume.0 as i128).sum();
311 features.lookback_vwap = Some(if total_volume_fp > 0 {
312 let vwap_raw = total_turnover / total_volume_fp;
313 FixedPoint(vwap_raw as i64)
314 } else {
315 FixedPoint(0)
316 });
317
318 let (low, high) = lookback.iter().fold((i64::MAX, i64::MIN), |acc, t| {
320 (acc.0.min(t.price.0), acc.1.max(t.price.0))
321 });
322 let range = (high - low) as f64;
323 let vwap_val = features.lookback_vwap.as_ref().map(|v| v.0).unwrap_or(0);
324 features.lookback_vwap_position = Some(if range > f64::EPSILON {
325 (vwap_val - low) as f64 / range
326 } else {
327 0.5 });
329 }
330
331 fn compute_tier2_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
333 let n = lookback.len();
334
335 if n >= 2 {
337 features.lookback_kyle_lambda = Some(compute_kyle_lambda(lookback));
338 }
339
340 if n >= 2 {
342 features.lookback_burstiness = Some(compute_burstiness(lookback));
343 }
344
345 if n >= 3 {
347 let (skew, kurt) = compute_volume_moments(lookback);
348 features.lookback_volume_skew = Some(skew);
349 if n >= 4 {
351 features.lookback_volume_kurt = Some(kurt);
352 }
353 }
354
355 if n >= 1 {
357 let first_price = lookback.first().unwrap().price.to_f64();
358 let (low, high) = lookback.iter().fold((i64::MAX, i64::MIN), |acc, t| {
359 (acc.0.min(t.price.0), acc.1.max(t.price.0))
360 });
361 let range = (high - low) as f64 / 1e8; features.lookback_price_range = Some(if first_price > f64::EPSILON {
363 range / first_price
364 } else {
365 0.0
366 });
367 }
368 }
369
370 fn compute_tier3_features(&self, lookback: &[&TradeSnapshot], features: &mut InterBarFeatures) {
372 let n = lookback.len();
373
374 let prices: Vec<f64> = lookback.iter().map(|t| t.price.to_f64()).collect();
376
377 if n >= 2 {
379 features.lookback_kaufman_er = Some(compute_kaufman_er(&prices));
380 }
381
382 if n >= 1 {
384 features.lookback_garman_klass_vol = Some(compute_garman_klass(lookback));
385 }
386
387 if n >= 64 {
389 features.lookback_hurst = Some(compute_hurst_dfa(&prices));
390 }
391
392 if n >= 60 {
394 features.lookback_permutation_entropy = Some(compute_permutation_entropy(&prices));
395 }
396 }
397
398 pub fn reset_bar_boundaries(&mut self) {
404 self.bar_close_indices.clear();
405 }
406
407 pub fn clear(&mut self) {
409 self.trades.clear();
410 }
411
412 pub fn len(&self) -> usize {
414 self.trades.len()
415 }
416
417 pub fn is_empty(&self) -> bool {
419 self.trades.is_empty()
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn create_test_snapshot(
429 timestamp: i64,
430 price: f64,
431 volume: f64,
432 is_buyer_maker: bool,
433 ) -> TradeSnapshot {
434 let price_fp = FixedPoint((price * 1e8) as i64);
435 let volume_fp = FixedPoint((volume * 1e8) as i64);
436 TradeSnapshot {
437 timestamp,
438 price: price_fp,
439 volume: volume_fp,
440 is_buyer_maker,
441 turnover: (price_fp.0 as i128) * (volume_fp.0 as i128),
442 }
443 }
444
445 #[test]
448 fn test_ofi_all_buys() {
449 let mut history = TradeHistory::new(InterBarConfig::default());
450
451 for i in 0..10 {
453 let trade = AggTrade {
454 agg_trade_id: i,
455 price: FixedPoint(5000000000000), volume: FixedPoint(100000000), first_trade_id: i,
458 last_trade_id: i,
459 timestamp: i * 1000,
460 is_buyer_maker: false, is_best_match: None,
462 };
463 history.push(&trade);
464 }
465
466 let features = history.compute_features(10000);
467
468 assert!(
469 (features.lookback_ofi.unwrap() - 1.0).abs() < f64::EPSILON,
470 "OFI should be 1.0 for all buys, got {}",
471 features.lookback_ofi.unwrap()
472 );
473 }
474
475 #[test]
476 fn test_ofi_all_sells() {
477 let mut history = TradeHistory::new(InterBarConfig::default());
478
479 for i in 0..10 {
481 let trade = AggTrade {
482 agg_trade_id: i,
483 price: FixedPoint(5000000000000),
484 volume: FixedPoint(100000000),
485 first_trade_id: i,
486 last_trade_id: i,
487 timestamp: i * 1000,
488 is_buyer_maker: true, is_best_match: None,
490 };
491 history.push(&trade);
492 }
493
494 let features = history.compute_features(10000);
495
496 assert!(
497 (features.lookback_ofi.unwrap() - (-1.0)).abs() < f64::EPSILON,
498 "OFI should be -1.0 for all sells, got {}",
499 features.lookback_ofi.unwrap()
500 );
501 }
502
503 #[test]
504 fn test_ofi_balanced() {
505 let mut history = TradeHistory::new(InterBarConfig::default());
506
507 for i in 0..10 {
509 let trade = AggTrade {
510 agg_trade_id: i,
511 price: FixedPoint(5000000000000),
512 volume: FixedPoint(100000000),
513 first_trade_id: i,
514 last_trade_id: i,
515 timestamp: i * 1000,
516 is_buyer_maker: i % 2 == 0, is_best_match: None,
518 };
519 history.push(&trade);
520 }
521
522 let features = history.compute_features(10000);
523
524 assert!(
525 features.lookback_ofi.unwrap().abs() < f64::EPSILON,
526 "OFI should be 0.0 for balanced volumes, got {}",
527 features.lookback_ofi.unwrap()
528 );
529 }
530
531 #[test]
534 fn test_burstiness_regular_intervals() {
535 let t0 = create_test_snapshot(0, 100.0, 1.0, false);
536 let t1 = create_test_snapshot(1000, 100.0, 1.0, false);
537 let t2 = create_test_snapshot(2000, 100.0, 1.0, false);
538 let t3 = create_test_snapshot(3000, 100.0, 1.0, false);
539 let t4 = create_test_snapshot(4000, 100.0, 1.0, false);
540 let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1, &t2, &t3, &t4];
541
542 let b = compute_burstiness(&lookback);
543
544 assert!(
546 (b - (-1.0)).abs() < 0.01,
547 "Burstiness should be -1 for regular intervals, got {}",
548 b
549 );
550 }
551
552 #[test]
555 fn test_kaufman_er_perfect_trend() {
556 let prices = vec![100.0, 101.0, 102.0, 103.0, 104.0];
557 let er = compute_kaufman_er(&prices);
558
559 assert!(
560 (er - 1.0).abs() < f64::EPSILON,
561 "Kaufman ER should be 1.0 for perfect trend, got {}",
562 er
563 );
564 }
565
566 #[test]
567 fn test_kaufman_er_round_trip() {
568 let prices = vec![100.0, 102.0, 104.0, 102.0, 100.0];
569 let er = compute_kaufman_er(&prices);
570
571 assert!(
572 er.abs() < f64::EPSILON,
573 "Kaufman ER should be 0.0 for round trip, got {}",
574 er
575 );
576 }
577
578 #[test]
581 fn test_permutation_entropy_monotonic() {
582 let prices: Vec<f64> = (1..=100).map(|i| i as f64).collect();
584 let pe = compute_permutation_entropy(&prices);
585
586 assert!(
587 pe.abs() < f64::EPSILON,
588 "PE should be 0 for monotonic, got {}",
589 pe
590 );
591 }
592
593 #[test]
596 fn test_lookback_excludes_current_bar_trades() {
597 let mut history = TradeHistory::new(InterBarConfig::default());
598
599 for i in 0..4 {
601 let trade = AggTrade {
602 agg_trade_id: i,
603 price: FixedPoint(5000000000000),
604 volume: FixedPoint(100000000),
605 first_trade_id: i,
606 last_trade_id: i,
607 timestamp: i * 1000,
608 is_buyer_maker: false,
609 is_best_match: None,
610 };
611 history.push(&trade);
612 }
613
614 let lookback = history.get_lookback_trades(2000);
616
617 assert_eq!(lookback.len(), 2, "Should have 2 trades before bar open");
619
620 for trade in &lookback {
621 assert!(
622 trade.timestamp < 2000,
623 "Trade at {} should be before bar open at 2000",
624 trade.timestamp
625 );
626 }
627 }
628
629 #[test]
632 fn test_count_imbalance_bounded() {
633 let mut history = TradeHistory::new(InterBarConfig::default());
634
635 for i in 0..100 {
637 let trade = AggTrade {
638 agg_trade_id: i,
639 price: FixedPoint(5000000000000),
640 volume: FixedPoint((i % 10 + 1) * 100000000),
641 first_trade_id: i,
642 last_trade_id: i,
643 timestamp: i * 1000,
644 is_buyer_maker: i % 3 == 0,
645 is_best_match: None,
646 };
647 history.push(&trade);
648 }
649
650 let features = history.compute_features(100000);
651 let imb = features.lookback_count_imbalance.unwrap();
652
653 assert!(
654 imb >= -1.0 && imb <= 1.0,
655 "Count imbalance should be in [-1, 1], got {}",
656 imb
657 );
658 }
659
660 #[test]
661 fn test_vwap_position_bounded() {
662 let mut history = TradeHistory::new(InterBarConfig::default());
663
664 for i in 0..20 {
666 let price = 50000.0 + (i as f64 * 10.0);
667 let trade = AggTrade {
668 agg_trade_id: i,
669 price: FixedPoint((price * 1e8) as i64),
670 volume: FixedPoint(100000000),
671 first_trade_id: i,
672 last_trade_id: i,
673 timestamp: i * 1000,
674 is_buyer_maker: false,
675 is_best_match: None,
676 };
677 history.push(&trade);
678 }
679
680 let features = history.compute_features(20000);
681 let pos = features.lookback_vwap_position.unwrap();
682
683 assert!(
684 pos >= 0.0 && pos <= 1.0,
685 "VWAP position should be in [0, 1], got {}",
686 pos
687 );
688 }
689
690 #[test]
691 fn test_hurst_soft_clamp_bounded() {
692 for raw_h in [-10.0, -1.0, 0.0, 0.5, 1.0, 2.0, 10.0] {
695 let clamped = soft_clamp_hurst(raw_h);
696 assert!(
697 clamped >= 0.0 && clamped <= 1.0,
698 "Hurst {} soft-clamped to {} should be in [0, 1]",
699 raw_h,
700 clamped
701 );
702 }
703
704 let h_half = soft_clamp_hurst(0.5);
706 assert!(
707 (h_half - 0.5).abs() < f64::EPSILON,
708 "Hurst 0.5 should map to 0.5, got {}",
709 h_half
710 );
711 }
712
713 #[test]
716 fn test_empty_lookback() {
717 let history = TradeHistory::new(InterBarConfig::default());
718 let features = history.compute_features(1000);
719
720 assert!(
721 features.lookback_trade_count.is_none() || features.lookback_trade_count == Some(0)
722 );
723 }
724
725 #[test]
726 fn test_single_trade_lookback() {
727 let mut history = TradeHistory::new(InterBarConfig::default());
728
729 let trade = AggTrade {
730 agg_trade_id: 0,
731 price: FixedPoint(5000000000000),
732 volume: FixedPoint(100000000),
733 first_trade_id: 0,
734 last_trade_id: 0,
735 timestamp: 0,
736 is_buyer_maker: false,
737 is_best_match: None,
738 };
739 history.push(&trade);
740
741 let features = history.compute_features(1000);
742
743 assert_eq!(features.lookback_trade_count, Some(1));
744 assert_eq!(features.lookback_duration_us, Some(0)); }
746
747 #[test]
748 fn test_kyle_lambda_zero_imbalance() {
749 let t0 = create_test_snapshot(0, 100.0, 1.0, false); let t1 = create_test_snapshot(1000, 102.0, 1.0, true); let lookback: Vec<&TradeSnapshot> = vec![&t0, &t1];
753
754 let lambda = compute_kyle_lambda(&lookback);
755
756 assert!(
757 lambda.is_finite(),
758 "Kyle lambda should be finite, got {}",
759 lambda
760 );
761 assert!(
762 lambda.abs() < f64::EPSILON,
763 "Kyle lambda should be 0 for zero imbalance"
764 );
765 }
766
767 fn make_trade(id: i64, timestamp: i64) -> AggTrade {
771 AggTrade {
772 agg_trade_id: id,
773 price: FixedPoint(5000000000000), volume: FixedPoint(100000000), first_trade_id: id,
776 last_trade_id: id,
777 timestamp,
778 is_buyer_maker: false,
779 is_best_match: None,
780 }
781 }
782
783 #[test]
784 fn test_bar_relative_bootstrap_keeps_all_trades() {
785 let config = InterBarConfig {
787 lookback_mode: LookbackMode::BarRelative(3),
788 compute_tier2: false,
789 compute_tier3: false,
790 };
791 let mut history = TradeHistory::new(config);
792
793 for i in 0..100 {
795 history.push(&make_trade(i, i * 1000));
796 }
797
798 assert_eq!(history.len(), 100, "Bootstrap phase should keep all trades");
799 }
800
801 #[test]
802 fn test_bar_relative_prunes_after_bar_close() {
803 let config = InterBarConfig {
804 lookback_mode: LookbackMode::BarRelative(2),
805 compute_tier2: false,
806 compute_tier3: false,
807 };
808 let mut history = TradeHistory::new(config);
809
810 for i in 0..10 {
812 history.push(&make_trade(i, i * 1000));
813 }
814 history.on_bar_close(); for i in 10..30 {
818 history.push(&make_trade(i, i * 1000));
819 }
820 history.on_bar_close(); for i in 30..35 {
824 history.push(&make_trade(i, i * 1000));
825 }
826 history.on_bar_close(); history.push(&make_trade(35, 35000));
842
843 assert!(
848 history.len() <= 26, "Should prune old bars, got {} trades",
850 history.len()
851 );
852 }
853
854 #[test]
855 fn test_bar_relative_mixed_bar_sizes() {
856 let config = InterBarConfig {
857 lookback_mode: LookbackMode::BarRelative(2),
858 compute_tier2: false,
859 compute_tier3: false,
860 };
861 let mut history = TradeHistory::new(config);
862
863 for i in 0..5 {
865 history.push(&make_trade(i, i * 1000));
866 }
867 history.on_bar_close();
868
869 for i in 5..55 {
871 history.push(&make_trade(i, i * 1000));
872 }
873 history.on_bar_close();
874
875 for i in 55..58 {
877 history.push(&make_trade(i, i * 1000));
878 }
879 history.on_bar_close();
880
881 history.push(&make_trade(58, 58000));
883
884 assert!(
890 history.len() <= 54, "Mixed bar sizes should prune correctly, got {} trades",
892 history.len()
893 );
894 }
895
896 #[test]
897 fn test_bar_relative_lookback_features_computed() {
898 let config = InterBarConfig {
899 lookback_mode: LookbackMode::BarRelative(3),
900 compute_tier2: false,
901 compute_tier3: false,
902 };
903 let mut history = TradeHistory::new(config);
904
905 for i in 0..20 {
907 let price = 50000.0 + (i as f64 * 10.0);
908 let trade = AggTrade {
909 agg_trade_id: i,
910 price: FixedPoint((price * 1e8) as i64),
911 volume: FixedPoint(100000000),
912 first_trade_id: i,
913 last_trade_id: i,
914 timestamp: i * 1000,
915 is_buyer_maker: i % 2 == 0,
916 is_best_match: None,
917 };
918 history.push(&trade);
919 }
920 history.on_bar_close();
922
923 history.on_bar_open(20000);
925
926 let features = history.compute_features(20000);
928
929 assert_eq!(features.lookback_trade_count, Some(20));
931 assert!(features.lookback_ofi.is_some());
932 assert!(features.lookback_intensity.is_some());
933 }
934
935 #[test]
936 fn test_bar_relative_reset_bar_boundaries() {
937 let config = InterBarConfig {
938 lookback_mode: LookbackMode::BarRelative(2),
939 compute_tier2: false,
940 compute_tier3: false,
941 };
942 let mut history = TradeHistory::new(config);
943
944 for i in 0..10 {
946 history.push(&make_trade(i, i * 1000));
947 }
948 history.on_bar_close();
949
950 assert_eq!(history.bar_close_indices.len(), 1);
951
952 history.reset_bar_boundaries();
954
955 assert!(
956 history.bar_close_indices.is_empty(),
957 "bar_close_indices should be empty after reset"
958 );
959 assert_eq!(
961 history.len(),
962 10,
963 "Trades should persist after boundary reset"
964 );
965 }
966
967 #[test]
968 fn test_bar_relative_on_bar_close_limits_indices() {
969 let config = InterBarConfig {
970 lookback_mode: LookbackMode::BarRelative(2),
971 compute_tier2: false,
972 compute_tier3: false,
973 };
974 let mut history = TradeHistory::new(config);
975
976 for bar_num in 0..5 {
978 for i in 0..5 {
979 history.push(&make_trade(bar_num * 5 + i, (bar_num * 5 + i) * 1000));
980 }
981 history.on_bar_close();
982 }
983
984 assert!(
986 history.bar_close_indices.len() <= 3,
987 "Should keep at most n+1 boundaries, got {}",
988 history.bar_close_indices.len()
989 );
990 }
991
992 #[test]
993 fn test_bar_relative_does_not_affect_fixed_count() {
994 let config = InterBarConfig {
996 lookback_mode: LookbackMode::FixedCount(10),
997 compute_tier2: false,
998 compute_tier3: false,
999 };
1000 let mut history = TradeHistory::new(config);
1001
1002 for i in 0..30 {
1003 history.push(&make_trade(i, i * 1000));
1004 }
1005 history.on_bar_close();
1007
1008 assert!(
1010 history.len() <= 20,
1011 "FixedCount(10) should keep at most 20 trades, got {}",
1012 history.len()
1013 );
1014 assert!(
1015 history.bar_close_indices.is_empty(),
1016 "FixedCount should not track bar boundaries"
1017 );
1018 }
1019
1020 #[test]
1023 fn test_volume_moments_numerical_accuracy() {
1024 let price_fp = FixedPoint((100.0 * 1e8) as i64);
1027 let snapshots: Vec<TradeSnapshot> = (1..=5_i64)
1028 .map(|v| {
1029 let volume_fp = FixedPoint((v as f64 * 1e8) as i64);
1030 TradeSnapshot {
1031 price: price_fp,
1032 volume: volume_fp,
1033 timestamp: v * 1000,
1034 is_buyer_maker: false,
1035 turnover: price_fp.0 as i128 * volume_fp.0 as i128,
1036 }
1037 })
1038 .collect();
1039 let refs: Vec<&TradeSnapshot> = snapshots.iter().collect();
1040 let (skew, kurt) = compute_volume_moments(&refs);
1041
1042 assert!(
1044 skew.abs() < 1e-10,
1045 "Symmetric distribution should have skewness ≈ 0, got {skew}"
1046 );
1047 assert!(
1049 (kurt - (-1.3)).abs() < 0.1,
1050 "Uniform-like kurtosis should be ≈ -1.3, got {kurt}"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_volume_moments_edge_cases() {
1056 let price_fp = FixedPoint((100.0 * 1e8) as i64);
1057
1058 let v1 = FixedPoint((1.0 * 1e8) as i64);
1060 let v2 = FixedPoint((2.0 * 1e8) as i64);
1061 let s1 = TradeSnapshot {
1062 price: price_fp,
1063 volume: v1,
1064 timestamp: 1000,
1065 is_buyer_maker: false,
1066 turnover: price_fp.0 as i128 * v1.0 as i128,
1067 };
1068 let s2 = TradeSnapshot {
1069 price: price_fp,
1070 volume: v2,
1071 timestamp: 2000,
1072 is_buyer_maker: false,
1073 turnover: price_fp.0 as i128 * v2.0 as i128,
1074 };
1075 let refs: Vec<&TradeSnapshot> = vec![&s1, &s2];
1076 let (skew, kurt) = compute_volume_moments(&refs);
1077 assert_eq!(skew, 0.0, "n < 3 should return 0");
1078 assert_eq!(kurt, 0.0, "n < 3 should return 0");
1079
1080 let vol = FixedPoint((5.0 * 1e8) as i64);
1082 let same: Vec<TradeSnapshot> = (0..10_i64)
1083 .map(|i| TradeSnapshot {
1084 price: price_fp,
1085 volume: vol,
1086 timestamp: i * 1000,
1087 is_buyer_maker: false,
1088 turnover: price_fp.0 as i128 * vol.0 as i128,
1089 })
1090 .collect();
1091 let refs: Vec<&TradeSnapshot> = same.iter().collect();
1092 let (skew, kurt) = compute_volume_moments(&refs);
1093 assert_eq!(skew, 0.0, "All same volume should return 0");
1094 assert_eq!(kurt, 0.0, "All same volume should return 0");
1095 }
1096}