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>
889 lines
28 KiB
Rust
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
|
|
}
|
|
}
|