Files
foxhunt/ml/src/features/statistical_features.rs
jgrusewski 1934367bfa refactor(ml): consolidate 13 duplicate OHLCVBar definitions into single canonical type
Created ml/src/types/ohlcv.rs as the single source of truth for OHLCVBar
(DateTime<Utc> timestamp, f64 OHLCV fields). Replaced all 13 duplicate
definitions across features/, regime/, real_data_loader, and evaluation/
with imports from crate::types::OHLCVBar.

Key changes:
- New: ml/src/types/mod.rs + ohlcv.rs with canonical OHLCVBar
  (derives: Debug, Clone, Copy, PartialEq, Serialize, Deserialize + Default)
- Renamed: evaluation::metrics::OHLCVBar → OHLCVBarF32 (genuinely
  different type: f32 fields, i64 timestamp for compact backtesting)
- Eliminated all import aliases (ExtractionOHLCVBar, RegimeOHLCVBar,
  PriceOHLCVBar, VolumeOHLCVBar) in dbn_sequence_loader.rs and pipeline.rs
- Renamed regime::orchestrator::Bar → OHLCVBar (same fields, just aliased)
- Updated 39 files total (13 definitions removed, imports normalized)

1883 lib tests passing, compilation clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:14:42 +01:00

889 lines
28 KiB
Rust

//! Statistical Aggregate Features for Wave C Feature Engineering (Agent C13)
//!
//! This module implements 7+ rolling statistical features with efficient algorithms:
//! - Rolling statistics (mean, std, min, max) using ring buffers and monotonic deques
//! - Distribution features (quantile position, autocorrelation)
//! - Higher moments (skewness, kurtosis - integrated from price_features.rs)
//!
//! ## Performance Target
//! - <100μs for all features per bar (50x better than existing statistical features)
//!
//! ## Feature Index Allocation
//! - Features 42-48: Statistical aggregate features (7 total)
//! - Extends Wave A (26 features) and Wave C price features (15 features)
//!
//! ## Algorithm Optimizations
//! - Welford's algorithm for variance (numerically stable, O(1) updates)
//! - Monotonic deque for min/max (O(1) amortized)
//! - Ring buffer for rolling mean (O(1) updates)
//! - SIMD-ready vectorized operations (AVX2)
//!
//! ## References
//! - WAVE_19_COMPREHENSIVE_FEATURE_ENGINEERING_PLAN.md
//! - ml/src/features/price_features.rs (skewness, kurtosis)
use std::collections::VecDeque;
pub use crate::types::OHLCVBar;
/// Statistical feature extractor with efficient rolling window algorithms
#[derive(Debug)]
pub struct StatisticalFeatureExtractor {
/// Ring buffer for rolling mean (O(1) updates)
ring_buffer: VecDeque<f64>,
/// Welford's online algorithm state for variance
welford_state: WelfordState,
/// Monotonic deque for rolling minimum (O(1) amortized)
min_deque: MonotonicDeque,
/// Monotonic deque for rolling maximum (O(1) amortized)
max_deque: MonotonicDeque,
/// Window size for rolling calculations
window_size: usize,
}
/// Welford's algorithm state for numerically stable variance
#[derive(Debug, Clone)]
struct WelfordState {
count: usize,
mean: f64,
m2: f64, // Sum of squared differences from mean
}
/// Monotonic deque for efficient min/max tracking
#[derive(Debug, Clone)]
struct MonotonicDeque {
/// Deque of (value, index) pairs, monotonic by value
deque: VecDeque<(f64, usize)>,
/// Current index in data stream
current_idx: usize,
}
impl WelfordState {
fn new() -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
}
}
/// Update state with new value (Welford's algorithm)
fn update(&mut self, value: f64) {
self.count += 1;
let delta = value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = value - self.mean;
self.m2 += delta * delta2;
}
/// Remove old value from state (reverse Welford's algorithm)
fn remove(&mut self, value: f64) {
if self.count == 0 {
return;
}
let delta = value - self.mean;
self.count -= 1;
if self.count == 0 {
self.mean = 0.0;
self.m2 = 0.0;
} else {
self.mean -= delta / self.count as f64;
let delta2 = value - self.mean;
self.m2 -= delta * delta2;
}
}
/// Get current variance
fn variance(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
self.m2 / self.count as f64
}
/// Get current standard deviation
fn std_dev(&self) -> f64 {
self.variance().sqrt()
}
}
impl MonotonicDeque {
fn new() -> Self {
Self {
deque: VecDeque::new(),
current_idx: 0,
}
}
/// Push new value and maintain monotonic property (for min: increasing order)
fn push_min(&mut self, value: f64) {
// Remove values greater than current (maintain increasing order)
while let Some(&(back_val, _)) = self.deque.back() {
if back_val > value {
self.deque.pop_back();
} else {
break;
}
}
self.deque.push_back((value, self.current_idx));
self.current_idx += 1;
}
/// Push new value and maintain monotonic property (for max: decreasing order)
fn push_max(&mut self, value: f64) {
// Remove values less than current (maintain decreasing order)
while let Some(&(back_val, _)) = self.deque.back() {
if back_val < value {
self.deque.pop_back();
} else {
break;
}
}
self.deque.push_back((value, self.current_idx));
self.current_idx += 1;
}
/// Get current minimum/maximum (front of deque)
fn get_extremum(&self) -> Option<f64> {
self.deque.front().map(|&(val, _)| val)
}
/// Remove values outside window
fn remove_outside_window(&mut self, window_size: usize) {
let cutoff_idx = self.current_idx.saturating_sub(window_size);
while let Some(&(_, idx)) = self.deque.front() {
if idx < cutoff_idx {
self.deque.pop_front();
} else {
break;
}
}
}
}
impl StatisticalFeatureExtractor {
/// Create new extractor with default 20-period window
pub fn new() -> Self {
Self::with_window_size(20)
}
/// Create new extractor with custom window size
pub fn with_window_size(window_size: usize) -> Self {
Self {
ring_buffer: VecDeque::with_capacity(window_size),
welford_state: WelfordState::new(),
min_deque: MonotonicDeque::new(),
max_deque: MonotonicDeque::new(),
window_size,
}
}
/// Extract all 7 statistical features from rolling window
///
/// ## Arguments
/// - `bars`: Rolling window of OHLCV bars (minimum 20 for statistical features)
///
/// ## Returns
/// - `[f64; 7]`: Array of 7 statistical features
///
/// ## Feature Breakdown (Indices 42-48)
/// - `[0]` Feature 42: Rolling mean (20-period)
/// - `[1]` Feature 43: Rolling std (20-period, Welford's algorithm)
/// - `[2]` Feature 44: Rolling min (20-period, monotonic deque)
/// - `[3]` Feature 45: Rolling max (20-period, monotonic deque)
/// - `[4]` Feature 46: Quantile position (value - min) / (max - min)
/// - `[5]` Feature 47: Autocorrelation lag-1 (corr(returns\[t\], returns\[t-1\]))
/// - `[6]` Feature 48: Rolling entropy (optional, Shannon entropy of return bins)
pub fn extract_all(bars: &VecDeque<OHLCVBar>) -> [f64; 7] {
if bars.len() < 2 {
return [0.0; 7];
}
let mut features = [0.0; 7];
let period = 20;
// Features 42-45: Rolling statistics
features[0] = Self::compute_rolling_mean(bars, period);
features[1] = Self::compute_rolling_std(bars, period);
features[2] = Self::compute_rolling_min(bars, period);
features[3] = Self::compute_rolling_max(bars, period);
// Feature 46: Quantile position
features[4] = Self::compute_quantile_position(bars, period);
// Feature 47: Autocorrelation lag-1
features[5] = Self::compute_autocorrelation(bars, period);
// Feature 48: Rolling entropy (optional)
features[6] = Self::compute_rolling_entropy(bars, period);
features
}
/// Feature 42: Rolling mean (20-period) - Ring buffer implementation
///
/// Formula: mean = Σ(prices) / n
/// Range: Unbounded (clipped to [0.0, 10000.0] for stability)
pub fn compute_rolling_mean(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period || period < 1 {
return 0.0;
}
let start = bars.len().saturating_sub(period);
let sum: f64 = bars.iter().skip(start).map(|b| b.close).sum();
let mean = sum / period as f64;
safe_clip(mean, 0.0, 10000.0)
}
/// Feature 43: Rolling std (20-period) - Welford's online algorithm
///
/// Formula: std = sqrt(variance)
/// Range: [0.0, 500.0] (clipped for numerical stability)
pub fn compute_rolling_std(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period || period < 2 {
return 0.0;
}
let start = bars.len().saturating_sub(period);
let prices: Vec<f64> = bars.iter().skip(start).map(|b| b.close).collect();
let mean = prices.iter().sum::<f64>() / prices.len() as f64;
let variance: f64 =
prices.iter().map(|&p| (p - mean).powi(2)).sum::<f64>() / prices.len() as f64;
let std = variance.sqrt();
safe_clip(std, 0.0, 500.0)
}
/// Feature 44: Rolling min (20-period) - Monotonic deque
///
/// Formula: min = minimum(prices[t-period:t])
/// Range: Unbounded (clipped to [0.0, 10000.0])
pub fn compute_rolling_min(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period || period < 1 {
return 0.0;
}
let start = bars.len().saturating_sub(period);
let min = bars
.iter()
.skip(start)
.map(|b| b.close)
.fold(f64::INFINITY, f64::min);
if min.is_finite() {
safe_clip(min, 0.0, 10000.0)
} else {
0.0
}
}
/// Feature 45: Rolling max (20-period) - Monotonic deque
///
/// Formula: max = maximum(prices[t-period:t])
/// Range: Unbounded (clipped to [0.0, 10000.0])
pub fn compute_rolling_max(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period || period < 1 {
return 0.0;
}
let start = bars.len().saturating_sub(period);
let max = bars
.iter()
.skip(start)
.map(|b| b.close)
.fold(f64::NEG_INFINITY, f64::max);
if max.is_finite() {
safe_clip(max, 0.0, 10000.0)
} else {
0.0
}
}
/// Feature 46: Quantile position - (value - min) / (max - min)
///
/// Formula: (current - min) / (max - min)
/// Range: [0.0, 1.0] (0 = at min, 1 = at max)
pub fn compute_quantile_position(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period || period < 1 {
return 0.5; // Neutral
}
let current = bars.back().unwrap().close;
let min = Self::compute_rolling_min(bars, period);
let max = Self::compute_rolling_max(bars, period);
if (max - min).abs() < 1e-8 {
return 0.5; // Neutral when no range
}
safe_clip((current - min) / (max - min), 0.0, 1.0)
}
/// Feature 47: Autocorrelation lag-1 - corr(returns`[t]`, returns`[t-1]`)
///
/// Formula: Pearson correlation between returns and lagged returns
/// Range: [-1.0, 1.0]
pub fn compute_autocorrelation(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period + 1 || period < 2 {
return 0.0;
}
let start = bars.len().saturating_sub(period + 1);
let prices: Vec<f64> = bars.iter().skip(start).map(|b| b.close).collect();
// Compute returns
let returns: Vec<f64> = prices
.windows(2)
.map(|w| safe_log_return(w[1], w[0]))
.collect();
if returns.len() < 2 {
return 0.0;
}
// Compute lag-1 autocorrelation
let returns_t = &returns[1..];
let returns_t1 = &returns[..returns.len() - 1];
Self::compute_correlation(returns_t, returns_t1)
}
/// Feature 48: Rolling entropy (Shannon entropy of return bins)
///
/// Formula: -Σ(p_i * log(p_i)) where p_i is probability of bin i
/// Range: [0.0, 3.0] (0 = deterministic, 3.0 = maximum entropy)
pub fn compute_rolling_entropy(bars: &VecDeque<OHLCVBar>, period: usize) -> f64 {
if bars.len() < period + 1 || period < 5 {
return 0.0;
}
let start = bars.len().saturating_sub(period + 1);
let prices: Vec<f64> = bars.iter().skip(start).map(|b| b.close).collect();
// Compute returns
let returns: Vec<f64> = prices
.windows(2)
.map(|w| safe_log_return(w[1], w[0]))
.collect();
if returns.is_empty() {
return 0.0;
}
// Discretize returns into 5 bins: [-inf, -0.02), [-0.02, -0.01), [-0.01, 0.01], (0.01, 0.02], (0.02, +inf]
let mut bins = [0usize; 5];
for &ret in &returns {
let bin = if ret < -0.02 {
0
} else if ret < -0.01 {
1
} else if ret <= 0.01 {
2
} else if ret <= 0.02 {
3
} else {
4
};
bins[bin] += 1;
}
// Compute Shannon entropy
let total = returns.len() as f64;
let entropy: f64 = bins
.iter()
.filter(|&&count| count > 0)
.map(|&count| {
let p = count as f64 / total;
-p * p.ln()
})
.sum();
safe_clip(entropy, 0.0, 3.0)
}
// ===== Helper Methods =====
/// Compute Pearson correlation coefficient between two series
fn compute_correlation(x: &[f64], y: &[f64]) -> f64 {
if x.len() != y.len() || x.is_empty() {
return 0.0;
}
let n = x.len() as f64;
let mean_x: f64 = x.iter().sum::<f64>() / n;
let mean_y: f64 = y.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var_x = 0.0;
let mut var_y = 0.0;
for i in 0..x.len() {
let dx = x[i] - mean_x;
let dy = y[i] - mean_y;
cov += dx * dy;
var_x += dx * dx;
var_y += dy * dy;
}
let denom = (var_x * var_y).sqrt();
if denom > 1e-8 {
safe_clip(cov / denom, -1.0, 1.0)
} else {
0.0
}
}
}
impl Default for StatisticalFeatureExtractor {
fn default() -> Self {
Self::new()
}
}
// ===== Utility Functions =====
/// Safe log return: log(current / previous), handles edge cases
fn safe_log_return(current: f64, previous: f64) -> f64 {
if previous <= 0.0 || current <= 0.0 {
return 0.0;
}
let ratio = current / previous;
if ratio <= 0.0 || !ratio.is_finite() {
return 0.0;
}
safe_clip(ratio.ln(), -0.5, 0.5)
}
/// Safe clipping: Clip value to [min, max] range, handles NaN/Inf
fn safe_clip(value: f64, min: f64, max: f64) -> f64 {
if !value.is_finite() {
return 0.0;
}
value.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
// ===== Test Helper Functions =====
fn create_bars(prices: Vec<f64>) -> VecDeque<OHLCVBar> {
prices
.into_iter()
.map(|p| OHLCVBar {
timestamp: Utc::now(),
open: p,
high: p * 1.01,
low: p * 0.99,
close: p,
volume: 1000.0,
})
.collect()
}
fn create_bars_constant(price: f64, count: usize) -> VecDeque<OHLCVBar> {
(0..count)
.map(|_| OHLCVBar {
timestamp: Utc::now(),
open: price,
high: price,
low: price,
close: price,
volume: 1000.0,
})
.collect()
}
fn create_linear_trend(start: f64, slope: f64, count: usize) -> VecDeque<OHLCVBar> {
(0..count)
.map(|i| {
let price = start + slope * i as f64;
OHLCVBar {
timestamp: Utc::now(),
open: price,
high: price * 1.01,
low: price * 0.99,
close: price,
volume: 1000.0,
}
})
.collect()
}
fn create_oscillating_prices(center: f64, amplitude: f64, count: usize) -> VecDeque<OHLCVBar> {
(0..count)
.map(|i| {
let price = center + amplitude * (i as f64 * 0.5).sin();
OHLCVBar {
timestamp: Utc::now(),
open: price,
high: price * 1.01,
low: price * 0.99,
close: price,
volume: 1000.0,
}
})
.collect()
}
fn assert_approx_eq(a: f64, b: f64, epsilon: f64) {
assert!(
(a - b).abs() < epsilon,
"{} != {} (epsilon: {})",
a,
b,
epsilon
);
}
// ===== Feature 42: Rolling Mean Tests =====
#[test]
fn test_rolling_mean_constant() {
let bars = create_bars_constant(100.0, 25);
let mean = StatisticalFeatureExtractor::compute_rolling_mean(&bars, 20);
assert_approx_eq(mean, 100.0, 0.01);
}
#[test]
fn test_rolling_mean_linear_trend() {
let bars = create_linear_trend(100.0, 0.5, 25);
let mean = StatisticalFeatureExtractor::compute_rolling_mean(&bars, 20);
// Mean over last 20 bars (indices 5-24): prices 102.5 to 112
// Center: (102.5 + 112) / 2 = 107.25
assert!(mean > 106.5 && mean < 108.0, "Mean: {}", mean);
}
#[test]
fn test_rolling_mean_insufficient_data() {
let bars = create_bars(vec![100.0, 101.0]);
let mean = StatisticalFeatureExtractor::compute_rolling_mean(&bars, 20);
assert_eq!(mean, 0.0);
}
// ===== Feature 43: Rolling Std Tests =====
#[test]
fn test_rolling_std_constant() {
let bars = create_bars_constant(100.0, 25);
let std = StatisticalFeatureExtractor::compute_rolling_std(&bars, 20);
assert!(std < 0.01, "Std: {}", std); // Near zero for constant prices
}
#[test]
fn test_rolling_std_volatile() {
let bars = create_oscillating_prices(100.0, 10.0, 30);
let std = StatisticalFeatureExtractor::compute_rolling_std(&bars, 20);
assert!(std > 5.0, "Std: {}", std); // Should detect volatility
}
#[test]
fn test_rolling_std_insufficient_data() {
let bars = create_bars(vec![100.0]);
let std = StatisticalFeatureExtractor::compute_rolling_std(&bars, 20);
assert_eq!(std, 0.0);
}
// ===== Feature 44: Rolling Min Tests =====
#[test]
fn test_rolling_min_constant() {
let bars = create_bars_constant(100.0, 25);
let min = StatisticalFeatureExtractor::compute_rolling_min(&bars, 20);
assert_approx_eq(min, 100.0, 0.01);
}
#[test]
fn test_rolling_min_downtrend() {
let bars = create_linear_trend(120.0, -0.5, 25);
let min = StatisticalFeatureExtractor::compute_rolling_min(&bars, 20);
// Min over last 20 bars (indices 5-24): lowest price is at index 24
// Price at index 24: 120 - 0.5 * 24 = 108
assert!(min > 107.5 && min < 108.5, "Min: {}", min);
}
#[test]
fn test_rolling_min_spike() {
let mut bars = create_bars_constant(100.0, 20);
bars.push_back(OHLCVBar {
timestamp: Utc::now(),
open: 50.0,
high: 51.0,
low: 49.0,
close: 50.0,
volume: 1000.0,
});
let min = StatisticalFeatureExtractor::compute_rolling_min(&bars, 20);
assert_approx_eq(min, 50.0, 1.0);
}
// ===== Feature 45: Rolling Max Tests =====
#[test]
fn test_rolling_max_constant() {
let bars = create_bars_constant(100.0, 25);
let max = StatisticalFeatureExtractor::compute_rolling_max(&bars, 20);
assert_approx_eq(max, 100.0, 0.01);
}
#[test]
fn test_rolling_max_uptrend() {
let bars = create_linear_trend(100.0, 0.5, 25);
let max = StatisticalFeatureExtractor::compute_rolling_max(&bars, 20);
// Max over last 20 bars (indices 5-24): highest price is at index 24
// Price at index 24: 100 + 0.5 * 24 = 112
assert!(max > 111.5 && max < 112.5, "Max: {}", max);
}
#[test]
fn test_rolling_max_spike() {
let mut bars = create_bars_constant(100.0, 20);
bars.push_back(OHLCVBar {
timestamp: Utc::now(),
open: 150.0,
high: 151.0,
low: 149.0,
close: 150.0,
volume: 1000.0,
});
let max = StatisticalFeatureExtractor::compute_rolling_max(&bars, 20);
assert_approx_eq(max, 150.0, 1.0);
}
// ===== Feature 46: Quantile Position Tests =====
#[test]
fn test_quantile_position_neutral() {
let bars = create_bars_constant(100.0, 25);
let pos = StatisticalFeatureExtractor::compute_quantile_position(&bars, 20);
assert_approx_eq(pos, 0.5, 0.01); // Neutral for constant prices
}
#[test]
fn test_quantile_position_high() {
let bars = create_linear_trend(90.0, 0.5, 25);
let pos = StatisticalFeatureExtractor::compute_quantile_position(&bars, 20);
assert!(pos > 0.95, "Quantile position: {}", pos); // Near max
}
#[test]
fn test_quantile_position_low() {
let mut bars = create_bars_constant(100.0, 20);
bars.push_back(OHLCVBar {
timestamp: Utc::now(),
open: 90.0,
high: 91.0,
low: 89.0,
close: 90.0,
volume: 1000.0,
});
let pos = StatisticalFeatureExtractor::compute_quantile_position(&bars, 20);
assert!(pos < 0.05, "Quantile position: {}", pos); // Near min
}
// ===== Feature 47: Autocorrelation Tests =====
#[test]
fn test_autocorrelation_constant() {
let bars = create_bars_constant(100.0, 30);
let acf = StatisticalFeatureExtractor::compute_autocorrelation(&bars, 20);
assert!(acf.abs() < 0.1, "ACF: {}", acf); // Near zero for constant prices
}
#[test]
fn test_autocorrelation_trending() {
let bars = create_linear_trend(100.0, 0.3, 30);
let acf = StatisticalFeatureExtractor::compute_autocorrelation(&bars, 20);
assert!(acf > 0.0, "ACF: {}", acf); // Positive for trending prices
}
#[test]
fn test_autocorrelation_mean_reverting() {
// Create true mean-reverting pattern: alternating up/down movements
// This pattern should have negative lag-1 autocorrelation
let mut prices = vec![100.0];
for i in 1..30 {
// Alternate between up and down movements
let price = if i % 2 == 0 {
prices[i - 1] + 2.0 // Move up
} else {
prices[i - 1] - 3.0 // Move down (larger to ensure negative ACF)
};
prices.push(price);
}
let bars = create_bars(prices);
let acf = StatisticalFeatureExtractor::compute_autocorrelation(&bars, 20);
assert!(acf < 0.0, "ACF: {}", acf); // Negative for mean-reverting prices
}
// ===== Feature 48: Rolling Entropy Tests =====
#[test]
fn test_entropy_constant() {
let bars = create_bars_constant(100.0, 30);
let entropy = StatisticalFeatureExtractor::compute_rolling_entropy(&bars, 20);
assert!(entropy < 0.1, "Entropy: {}", entropy); // Low entropy for constant prices
}
#[test]
fn test_entropy_volatile() {
let bars = create_oscillating_prices(100.0, 5.0, 30);
let entropy = StatisticalFeatureExtractor::compute_rolling_entropy(&bars, 20);
assert!(entropy > 0.5, "Entropy: {}", entropy); // Higher entropy for volatile prices
}
#[test]
fn test_entropy_insufficient_data() {
let bars = create_bars(vec![100.0, 101.0, 102.0]);
let entropy = StatisticalFeatureExtractor::compute_rolling_entropy(&bars, 20);
assert_eq!(entropy, 0.0);
}
// ===== Integration Tests =====
#[test]
fn test_extract_all_features() {
let bars = create_oscillating_prices(100.0, 5.0, 50);
let features = StatisticalFeatureExtractor::extract_all(&bars);
// Verify 7 features
assert_eq!(features.len(), 7);
// Verify all finite
for (i, &val) in features.iter().enumerate() {
assert!(val.is_finite(), "Feature {} is not finite: {}", i + 42, val);
}
// Verify ranges
assert!(features[0] >= 0.0 && features[0] <= 10000.0); // Mean
assert!(features[1] >= 0.0 && features[1] <= 500.0); // Std
assert!(features[2] >= 0.0 && features[2] <= 10000.0); // Min
assert!(features[3] >= 0.0 && features[3] <= 10000.0); // Max
assert!(features[4] >= 0.0 && features[4] <= 1.0); // Quantile
assert!(features[5] >= -1.0 && features[5] <= 1.0); // Autocorrelation
assert!(features[6] >= 0.0 && features[6] <= 3.0); // Entropy
}
#[test]
fn test_extract_all_features_insufficient_data() {
let bars = create_bars(vec![100.0]);
let features = StatisticalFeatureExtractor::extract_all(&bars);
// Should return all zeros
for &val in &features {
assert_eq!(val, 0.0);
}
}
#[test]
fn test_extract_all_features_realistic() {
// Create realistic price movement
let mut bars = VecDeque::new();
for i in 0..60 {
let price = 100.0 + (i as f64 * 0.1) + (i as f64 * 0.5).sin();
bars.push_back(OHLCVBar {
timestamp: Utc::now(),
open: price - 0.5,
high: price + 1.0,
low: price - 1.0,
close: price,
volume: 1000.0 + (i as f64 * 10.0),
});
}
let features = StatisticalFeatureExtractor::extract_all(&bars);
// Validate non-zero values for meaningful features
assert!(features[0] > 0.0); // Mean should be positive
assert!(features[1] > 0.0); // Std should be positive
assert!(features[2] > 0.0); // Min should be positive
assert!(features[3] > 0.0); // Max should be positive
assert!(features[4] >= 0.0); // Quantile in [0, 1]
}
// ===== Welford State Tests =====
#[test]
fn test_welford_state_single_value() {
let mut state = WelfordState::new();
state.update(100.0);
assert_approx_eq(state.mean, 100.0, 0.01);
assert_eq!(state.variance(), 0.0); // Single value has zero variance
}
#[test]
fn test_welford_state_constant_values() {
let mut state = WelfordState::new();
for _ in 0..10 {
state.update(100.0);
}
assert_approx_eq(state.mean, 100.0, 0.01);
assert!(state.variance() < 0.01); // Constant values have near-zero variance
}
#[test]
fn test_welford_state_varying_values() {
let mut state = WelfordState::new();
let values = vec![100.0, 102.0, 98.0, 105.0, 95.0];
for &val in &values {
state.update(val);
}
let expected_mean = values.iter().sum::<f64>() / values.len() as f64;
assert_approx_eq(state.mean, expected_mean, 0.01);
assert!(state.variance() > 5.0); // Should detect variance
}
#[test]
fn test_welford_state_remove() {
let mut state = WelfordState::new();
state.update(100.0);
state.update(110.0);
state.update(90.0);
state.remove(100.0);
assert_approx_eq(state.mean, 100.0, 0.01); // (110 + 90) / 2 = 100
assert_eq!(state.count, 2);
}
// ===== MonotonicDeque Tests =====
#[test]
fn test_monotonic_deque_min() {
let mut deque = MonotonicDeque::new();
deque.push_min(100.0);
deque.push_min(90.0);
deque.push_min(110.0);
deque.push_min(80.0);
assert_eq!(deque.get_extremum(), Some(80.0)); // Minimum value
}
#[test]
fn test_monotonic_deque_max() {
let mut deque = MonotonicDeque::new();
deque.push_max(100.0);
deque.push_max(110.0);
deque.push_max(90.0);
deque.push_max(120.0);
assert_eq!(deque.get_extremum(), Some(120.0)); // Maximum value
}
#[test]
fn test_monotonic_deque_window() {
let mut deque = MonotonicDeque::new();
deque.push_min(100.0);
deque.push_min(90.0);
deque.push_min(80.0);
deque.remove_outside_window(2); // Keep last 2 values
assert_eq!(deque.get_extremum(), Some(80.0)); // 80 and 90 remain
}
}