1use 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#[derive(Debug, Clone, Default)]
24pub struct IntraBarFeatures {
25 pub intra_bull_epoch_density: Option<f64>,
28 pub intra_bear_epoch_density: Option<f64>,
30 pub intra_bull_excess_gain: Option<f64>,
32 pub intra_bear_excess_gain: Option<f64>,
34 pub intra_bull_cv: Option<f64>,
36 pub intra_bear_cv: Option<f64>,
38 pub intra_max_drawdown: Option<f64>,
40 pub intra_max_runup: Option<f64>,
42
43 pub intra_trade_count: Option<u32>,
46 pub intra_ofi: Option<f64>,
48 pub intra_duration_us: Option<i64>,
50 pub intra_intensity: Option<f64>,
52 pub intra_vwap_position: Option<f64>,
54 pub intra_count_imbalance: Option<f64>,
56 pub intra_kyle_lambda: Option<f64>,
58 pub intra_burstiness: Option<f64>,
60 pub intra_volume_skew: Option<f64>,
62 pub intra_volume_kurt: Option<f64>,
64 pub intra_kaufman_er: Option<f64>,
66 pub intra_garman_klass_vol: Option<f64>,
68
69 pub intra_hurst: Option<f64>,
72 pub intra_permutation_entropy: Option<f64>,
74}
75
76pub 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 let prices: Vec<f64> = trades.iter().map(|t| t.price.to_f64()).collect();
98
99 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 let max_dd = compute_max_drawdown(&normalized);
111 let max_ru = compute_max_runup(&normalized);
112
113 let bull_result = bull_ith(&normalized, max_dd);
115
116 let bear_result = bear_ith(&normalized, max_ru);
118
119 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 let stats = compute_statistical_features(trades, &prices);
125
126 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 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 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 intra_hurst: hurst,
165 intra_permutation_entropy: pe,
166 }
167}
168
169struct 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
184fn compute_statistical_features(trades: &[AggTrade], prices: &[f64]) -> StatisticalFeatures {
186 let n = trades.len();
187
188 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 let ofi = if total_vol > f64::EPSILON {
215 (buy_vol - sell_vol) / total_vol
216 } else {
217 0.0
218 };
219
220 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 let intensity = if duration_sec > f64::EPSILON {
228 n as f64 / duration_sec
229 } else {
230 n as f64 };
232
233 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 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 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 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 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 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) } else {
329 None
330 }
331 } else {
332 None
333 };
334
335 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) }
345 } else {
346 None
347 };
348
349 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
376fn compute_hurst_dfa(prices: &[f64]) -> f64 {
385 let n = prices.len();
386 if n < 64 {
387 return 0.5; }
389
390 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 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 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 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 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 1.0 / (1.0 + (-4.0 * (hurst - 0.5)).exp())
491}
492
493fn 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; }
506
507 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 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 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 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 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
545fn 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 assert!(features.intra_bull_epoch_density.is_none());
587 }
588
589 #[test]
590 fn test_compute_intra_bar_features_uptrend() {
591 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 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 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 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 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 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 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 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 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 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 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 if let Some(h) = features.intra_hurst {
747 assert!(h >= 0.0 && h <= 1.0, "Hurst out of bounds: {}", h);
748 }
749 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}