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>
883 lines
26 KiB
Rust
883 lines
26 KiB
Rust
//! Volume-Based Features for Wave C Feature Engineering
|
||
//!
|
||
//! This module implements 10 advanced volume features to complement the existing
|
||
//! 40 volume features in extraction.rs. These features capture volume dynamics,
|
||
//! price-volume relationships, and market participation patterns.
|
||
//!
|
||
//! ## Features Implemented (Indices 256-265)
|
||
//! 1. Volume Ratio to SMA-50 (256)
|
||
//! 2. Volume ROC 5-period (257)
|
||
//! 3. Volume ROC 10-period (258)
|
||
//! 4. Volume Acceleration (259)
|
||
//! 5. Volume Trend Slope (260)
|
||
//! 6. VWAP Intraday Deviation (261)
|
||
//! 7. Volume-Price Correlation (262)
|
||
//! 8. Volume Percentile 10-period (263)
|
||
//! 9. Volume Concentration HHI (264)
|
||
//! 10. Volume Imbalance Buy/Sell (265)
|
||
//!
|
||
//! ## Performance Target
|
||
//! - Latency: <150μs for all 10 features per bar
|
||
//! - Memory: <100 bytes per bar (reuses existing VecDeque)
|
||
//!
|
||
//! ## Integration
|
||
//! These features extend the 256-dimension feature vector to 266 dimensions.
|
||
//!
|
||
//! ## References
|
||
//! - WAVE_C_VOLUME_FEATURES_DESIGN.md (comprehensive design document)
|
||
//! - ml/src/features/extraction.rs (existing 40 volume features)
|
||
|
||
use anyhow::Result;
|
||
use std::collections::VecDeque;
|
||
|
||
pub use crate::types::OHLCVBar;
|
||
|
||
/// Volume feature extractor with stateful rolling windows (Wave G17 optimized)
|
||
///
|
||
/// Memory Optimization:
|
||
/// - OLD: `VecDeque<OHLCVBar>` with capacity 260 → ~32.5KB per symbol (always allocated)
|
||
/// - NEW: `Option<VecDeque<OHLCVBar>>` → 0 bytes until first bar (lazy allocated)
|
||
/// - Savings: ~32KB per symbol × 100K symbols (for unused symbols) = 3.2 GB
|
||
///
|
||
/// Note: Lazy allocation via Option reduces memory for unused symbols to zero.
|
||
#[derive(Debug)]
|
||
pub struct VolumeFeatureExtractor {
|
||
/// Rolling window of bars (Wave G17: lazy allocation for 100% savings on unused symbols)
|
||
bars: Option<VecDeque<OHLCVBar>>,
|
||
}
|
||
|
||
impl VolumeFeatureExtractor {
|
||
/// Creates a new volume feature extractor (Wave G17: lazy allocation)
|
||
pub fn new() -> Self {
|
||
Self { bars: None }
|
||
}
|
||
|
||
/// Updates the extractor with a new bar
|
||
pub fn update(&mut self, bar: &OHLCVBar) {
|
||
let buffer = self
|
||
.bars
|
||
.get_or_insert_with(|| VecDeque::with_capacity(260));
|
||
buffer.push_back(*bar);
|
||
if buffer.len() > 260 {
|
||
buffer.pop_front();
|
||
}
|
||
}
|
||
|
||
/// Helper: Get bars reference
|
||
fn bars(&self) -> &VecDeque<OHLCVBar> {
|
||
static EMPTY: once_cell::sync::Lazy<VecDeque<OHLCVBar>> =
|
||
once_cell::sync::Lazy::new(VecDeque::new);
|
||
self.bars.as_ref().unwrap_or(&EMPTY)
|
||
}
|
||
|
||
/// Extracts all 10 volume features (indices 256-265)
|
||
///
|
||
/// ## Returns
|
||
/// - Array of 10 features: [256, 257, ..., 265]
|
||
///
|
||
/// ## Performance
|
||
/// - Target: <150μs per call
|
||
/// - Complexity: O(1) amortized for most features, O(n) for correlation/HHI
|
||
pub fn extract_features(&self) -> Result<[f64; 10]> {
|
||
let mut features = [0.0; 10];
|
||
|
||
// Feature 256: Volume ratio to SMA-50
|
||
features[0] = self.compute_volume_ratio_sma50();
|
||
|
||
// Feature 257: Volume ROC 5-period
|
||
features[1] = self.compute_volume_roc(5);
|
||
|
||
// Feature 258: Volume ROC 10-period
|
||
features[2] = self.compute_volume_roc(10);
|
||
|
||
// Feature 259: Volume acceleration
|
||
features[3] = self.compute_volume_acceleration();
|
||
|
||
// Feature 260: Volume trend slope (20-period linear regression)
|
||
features[4] = self.compute_volume_trend_slope(20);
|
||
|
||
// Feature 261: VWAP intraday deviation
|
||
features[5] = self.compute_vwap_deviation();
|
||
|
||
// Feature 262: Volume-price correlation (20-period)
|
||
features[6] = self.compute_volume_price_correlation(20);
|
||
|
||
// Feature 263: Volume percentile (10-period)
|
||
features[7] = self.compute_volume_percentile(10);
|
||
|
||
// Feature 264: Volume concentration HHI (20-period)
|
||
features[8] = self.compute_volume_concentration_hhi(20);
|
||
|
||
// Feature 265: Volume imbalance (5-period buy/sell)
|
||
features[9] = self.compute_volume_imbalance(5);
|
||
|
||
// Validate no NaN/Inf
|
||
for (i, &val) in features.iter().enumerate() {
|
||
if !val.is_finite() {
|
||
anyhow::bail!("Invalid volume feature at index {}: {}", i + 256, val);
|
||
}
|
||
}
|
||
|
||
Ok(features)
|
||
}
|
||
|
||
// ===== Feature Implementation Methods =====
|
||
|
||
/// Feature 256: Volume ratio to SMA-50
|
||
///
|
||
/// Formula: (current_volume - sma_50) / sma_50
|
||
/// Range: [-2.0, 5.0]
|
||
fn compute_volume_ratio_sma50(&self) -> f64 {
|
||
if self.bars().len() < 50 {
|
||
return 0.0;
|
||
}
|
||
|
||
let bar = self.bars().back().unwrap();
|
||
let sma_50 = self.compute_volume_sma(50);
|
||
|
||
let ratio = (bar.volume - sma_50) / (sma_50 + 1e-8);
|
||
safe_clip(ratio, -2.0, 5.0)
|
||
}
|
||
|
||
/// Feature 257/258: Volume ROC (Rate of Change)
|
||
///
|
||
/// Formula: (current_volume - volume_n_bars_ago) / volume_n_bars_ago
|
||
/// Range: [-1.0, 3.0]
|
||
fn compute_volume_roc(&self, period: usize) -> f64 {
|
||
let len = self.bars().len();
|
||
if len <= period {
|
||
return 0.0;
|
||
}
|
||
|
||
let curr_vol = self.bars().back().unwrap().volume;
|
||
let prev_vol = self.bars()[len - period - 1].volume;
|
||
|
||
let roc = (curr_vol - prev_vol) / (prev_vol + 1e-8);
|
||
safe_clip(roc, -1.0, 3.0)
|
||
}
|
||
|
||
/// Feature 259: Volume acceleration (second derivative)
|
||
///
|
||
/// Formula: (velocity_1 - velocity_2) / 1000
|
||
/// Range: [-5.0, 5.0]
|
||
fn compute_volume_acceleration(&self) -> f64 {
|
||
if self.bars().len() < 3 {
|
||
return 0.0;
|
||
}
|
||
|
||
let curr = self.bars().back().unwrap().volume;
|
||
let prev1 = self.bars()[self.bars().len() - 2].volume;
|
||
let prev2 = self.bars()[self.bars().len() - 3].volume;
|
||
|
||
let vel1 = curr - prev1;
|
||
let vel2 = prev1 - prev2;
|
||
let accel = vel1 - vel2;
|
||
|
||
safe_clip(accel / 1000.0, -5.0, 5.0)
|
||
}
|
||
|
||
/// Feature 260: Volume trend slope (linear regression)
|
||
///
|
||
/// Formula: Linear regression slope over period
|
||
/// Range: [-1.0, 1.0]
|
||
fn compute_volume_trend_slope(&self, period: usize) -> f64 {
|
||
if self.bars().len() < period {
|
||
return 0.0;
|
||
}
|
||
|
||
let start = self.bars().len() - period;
|
||
let n = period as f64;
|
||
|
||
// Linear regression formula: slope = (n*Σxy - Σx*Σy) / (n*Σx² - (Σx)²)
|
||
let sum_x = (n * (n - 1.0)) / 2.0;
|
||
let sum_x2 = (n * (n - 1.0) * (2.0 * n - 1.0)) / 6.0;
|
||
|
||
let mut sum_y = 0.0;
|
||
let mut sum_xy = 0.0;
|
||
|
||
for (i, bar) in self.bars().iter().skip(start).enumerate() {
|
||
sum_y += bar.volume;
|
||
sum_xy += i as f64 * bar.volume;
|
||
}
|
||
|
||
let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x);
|
||
safe_clip(slope / 100.0, -1.0, 1.0)
|
||
}
|
||
|
||
/// Feature 261: VWAP intraday deviation
|
||
///
|
||
/// Formula: (close - vwap) / close
|
||
/// Range: [-0.1, 0.1]
|
||
fn compute_vwap_deviation(&self) -> f64 {
|
||
if self.bars().len() < 20 {
|
||
return 0.0;
|
||
}
|
||
|
||
let bar = self.bars().back().unwrap();
|
||
let vwap = self.compute_vwap(20);
|
||
|
||
let deviation = (bar.close - vwap) / (bar.close + 1e-8);
|
||
safe_clip(deviation, -0.1, 0.1)
|
||
}
|
||
|
||
/// Feature 262: Volume-price correlation (Pearson)
|
||
///
|
||
/// Formula: Pearson correlation coefficient
|
||
/// Range: [-1.0, 1.0]
|
||
fn compute_volume_price_correlation(&self, period: usize) -> f64 {
|
||
if self.bars().len() < period {
|
||
return 0.0;
|
||
}
|
||
|
||
let start = self.bars().len() - period;
|
||
|
||
let prices: Vec<f64> = self.bars().iter().skip(start).map(|b| b.close).collect();
|
||
let volumes: Vec<f64> = self.bars().iter().skip(start).map(|b| b.volume).collect();
|
||
|
||
self.compute_correlation(&prices, &volumes)
|
||
}
|
||
|
||
/// Feature 263: Volume percentile rank
|
||
///
|
||
/// Formula: count(vol < current_vol) / period
|
||
/// Range: [0.0, 1.0]
|
||
fn compute_volume_percentile(&self, period: usize) -> f64 {
|
||
if self.bars().len() < period {
|
||
return 0.5; // Neutral
|
||
}
|
||
|
||
let current_vol = self.bars().back().unwrap().volume;
|
||
let start = self.bars().len() - period;
|
||
|
||
let count_below = self
|
||
.bars()
|
||
.iter()
|
||
.skip(start)
|
||
.filter(|b| b.volume < current_vol)
|
||
.count();
|
||
|
||
count_below as f64 / period as f64
|
||
}
|
||
|
||
/// Feature 264: Volume concentration (Herfindahl-Hirschman Index)
|
||
///
|
||
/// Formula: HHI = Σ(vol_i / total_vol)²
|
||
/// Range: [0.0, 1.0] (normalized from [1/n, 1])
|
||
fn compute_volume_concentration_hhi(&self, period: usize) -> f64 {
|
||
if self.bars().len() < period {
|
||
return 0.5; // Neutral
|
||
}
|
||
|
||
let start = self.bars().len() - period;
|
||
let total_vol: f64 = self.bars().iter().skip(start).map(|b| b.volume).sum();
|
||
|
||
if total_vol < 1e-8 {
|
||
return 0.5; // Neutral for zero volume
|
||
}
|
||
|
||
let hhi: f64 = self
|
||
.bars()
|
||
.iter()
|
||
.skip(start)
|
||
.map(|b| {
|
||
let share = b.volume / total_vol;
|
||
share * share
|
||
})
|
||
.sum();
|
||
|
||
// Normalize: HHI ∈ [1/n, 1] → [0, 1]
|
||
let min_hhi = 1.0 / period as f64;
|
||
let normalized = (hhi - min_hhi) / (1.0 - min_hhi);
|
||
|
||
safe_clip(normalized, 0.0, 1.0)
|
||
}
|
||
|
||
/// Feature 265: Volume imbalance (buy vs sell pressure)
|
||
///
|
||
/// Formula: (buy_vol - sell_vol) / total_vol
|
||
/// Range: [-1.0, 1.0]
|
||
fn compute_volume_imbalance(&self, period: usize) -> f64 {
|
||
if self.bars().len() < period {
|
||
return 0.0;
|
||
}
|
||
|
||
let start = self.bars().len() - period;
|
||
let mut buy_vol = 0.0;
|
||
let mut sell_vol = 0.0;
|
||
|
||
for bar in self.bars().iter().skip(start) {
|
||
if bar.close > bar.open {
|
||
buy_vol += bar.volume;
|
||
} else if bar.close < bar.open {
|
||
sell_vol += bar.volume;
|
||
}
|
||
// Doji bars (close == open) contribute to neither
|
||
}
|
||
|
||
let total_vol = buy_vol + sell_vol + 1e-8;
|
||
let imbalance = (buy_vol - sell_vol) / total_vol;
|
||
|
||
safe_clip(imbalance, -1.0, 1.0)
|
||
}
|
||
|
||
// ===== Helper Methods (reuse extraction.rs patterns) =====
|
||
|
||
fn compute_volume_sma(&self, period: usize) -> f64 {
|
||
let start = self.bars().len().saturating_sub(period);
|
||
let sum: f64 = self.bars().iter().skip(start).map(|b| b.volume).sum();
|
||
sum / period as f64
|
||
}
|
||
|
||
fn compute_vwap(&self, period: usize) -> f64 {
|
||
let start = self.bars().len().saturating_sub(period);
|
||
let (weighted_sum, volume_sum): (f64, f64) = self
|
||
.bars()
|
||
.iter()
|
||
.skip(start)
|
||
.map(|b| (b.close * b.volume, b.volume))
|
||
.fold((0.0, 0.0), |(ws, vs), (w, v)| (ws + w, vs + v));
|
||
weighted_sum / (volume_sum + 1e-8)
|
||
}
|
||
|
||
fn compute_correlation(&self, 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 VolumeFeatureExtractor {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
// ===== Utility Functions =====
|
||
|
||
/// 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;
|
||
|
||
fn create_bars_with_volume(volumes: Vec<f64>) -> Vec<OHLCVBar> {
|
||
volumes
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, &vol)| OHLCVBar {
|
||
timestamp: Utc::now() + chrono::Duration::hours(i as i64),
|
||
open: 100.0,
|
||
high: 101.0,
|
||
low: 99.0,
|
||
close: 100.5,
|
||
volume: vol,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn create_bars_with_price_volume(prices: Vec<f64>, volumes: Vec<f64>) -> Vec<OHLCVBar> {
|
||
prices
|
||
.iter()
|
||
.zip(volumes.iter())
|
||
.enumerate()
|
||
.map(|(i, (&p, &v))| OHLCVBar {
|
||
timestamp: Utc::now() + chrono::Duration::hours(i as i64),
|
||
open: p,
|
||
high: p + 1.0,
|
||
low: p - 1.0,
|
||
close: p,
|
||
volume: v,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn create_bars_with_ohlc(ohlc: Vec<(f64, f64)>, volumes: Vec<f64>) -> Vec<OHLCVBar> {
|
||
ohlc.iter()
|
||
.zip(volumes.iter())
|
||
.enumerate()
|
||
.map(|(i, (&(o, c), &v))| OHLCVBar {
|
||
timestamp: Utc::now() + chrono::Duration::hours(i as i64),
|
||
open: o,
|
||
high: o.max(c) + 1.0,
|
||
low: o.min(c) - 1.0,
|
||
close: c,
|
||
volume: v,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_ratio_normal() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0; 51]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[0] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[0]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_ratio_2x_spike() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let mut volumes = vec![1000.0; 50];
|
||
volumes.push(2000.0);
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
// SMA-50 = (49*1000 + 2000) / 50 = 1020
|
||
// Ratio = (2000 - 1020) / 1020 = 0.96
|
||
assert!(
|
||
(features[0] - 0.96).abs() < 0.02,
|
||
"Expected 0.96, got {}",
|
||
features[0]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_ratio_extreme_clipping() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let mut volumes = vec![1000.0; 50];
|
||
volumes.push(10000.0);
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[0] - 5.0).abs() < 0.01,
|
||
"Expected 5.0 (clipped), got {}",
|
||
features[0]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_roc_5_flat() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0; 10]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[1] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[1]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_roc_5_doubling() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let volumes = vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 2000.0];
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[1] - 1.0).abs() < 0.01,
|
||
"Expected 1.0, got {}",
|
||
features[1]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_acceleration_constant() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0, 1100.0, 1200.0]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[3] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[3]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_acceleration_positive() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0, 1100.0, 1300.0]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
features[3] > 0.0,
|
||
"Expected positive acceleration, got {}",
|
||
features[3]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_trend_flat() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0; 25]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[4] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[4]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_trend_uptrend() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let volumes: Vec<f64> = (1000..1025).map(|x| x as f64 * 100.0).collect();
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
features[4] > 0.0,
|
||
"Expected positive slope, got {}",
|
||
features[4]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_vwap_at_fair_value() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_price_volume(vec![100.0; 25], vec![1000.0; 25]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[5] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[5]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_price_correlation_positive() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let prices: Vec<f64> = (100..120).map(|x| x as f64).collect();
|
||
let volumes: Vec<f64> = (1000..1020).map(|x| x as f64 * 100.0).collect();
|
||
let bars = create_bars_with_price_volume(prices, volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
features[6] > 0.5,
|
||
"Expected strong positive correlation, got {}",
|
||
features[6]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_price_correlation_negative() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let prices: Vec<f64> = (100..120).rev().map(|x| x as f64).collect();
|
||
let volumes: Vec<f64> = (1000..1020).map(|x| x as f64 * 100.0).collect();
|
||
let bars = create_bars_with_price_volume(prices, volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
features[6] < -0.5,
|
||
"Expected strong negative correlation, got {}",
|
||
features[6]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_percentile_minimum() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let mut volumes = vec![1000.0; 10];
|
||
volumes[9] = 500.0;
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[7] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[7]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_percentile_maximum() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let mut volumes = vec![1000.0; 10];
|
||
volumes[9] = 2000.0;
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[7] - 1.0).abs() < 0.11,
|
||
"Expected 1.0, got {}",
|
||
features[7]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_concentration_uniform() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0; 25]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[8] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[8]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_concentration_high() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
// Create more extreme concentration: 19 very small + 1 dominant volume
|
||
let mut volumes = vec![10.0; 19];
|
||
volumes.push(9900.0);
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
// Total = 19*10 + 9900 = 10090
|
||
// HHI = 19*(10/10090)² + (9900/10090)² ≈ 0.000019 + 0.963 = 0.963
|
||
// min_hhi = 1/20 = 0.05
|
||
// normalized = (0.963 - 0.05) / (1 - 0.05) = 0.96
|
||
assert!(
|
||
features[8] > 0.9,
|
||
"Expected high HHI (>0.9), got {}",
|
||
features[8]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_imbalance_balanced() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let ohlc = vec![(100.0, 100.0); 5]; // Doji bars
|
||
let bars = create_bars_with_ohlc(ohlc, vec![1000.0; 5]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[9] - 0.0).abs() < 0.01,
|
||
"Expected 0.0, got {}",
|
||
features[9]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_imbalance_buying() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let ohlc = vec![(100.0, 110.0); 5]; // All bullish bars
|
||
let bars = create_bars_with_ohlc(ohlc, vec![1000.0; 5]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[9] - 1.0).abs() < 0.01,
|
||
"Expected 1.0, got {}",
|
||
features[9]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_volume_imbalance_selling() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let ohlc = vec![(110.0, 100.0); 5]; // All bearish bars
|
||
let bars = create_bars_with_ohlc(ohlc, vec![1000.0; 5]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
assert!(
|
||
(features[9] - -1.0).abs() < 0.01,
|
||
"Expected -1.0, got {}",
|
||
features[9]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_insufficient_history_returns_default() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1000.0; 3]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
// Should succeed but return mostly 0.0 values
|
||
let features = extractor.extract_features().unwrap();
|
||
|
||
// Most features should be 0.0 or neutral (0.5 for percentile/HHI)
|
||
assert!((features[0] - 0.0).abs() < 0.01); // Volume ratio (insufficient)
|
||
assert!((features[7] - 0.5).abs() < 0.01); // Percentile (neutral)
|
||
assert!((features[8] - 0.5).abs() < 0.01); // HHI (neutral)
|
||
}
|
||
|
||
#[test]
|
||
fn test_zero_volume_handling() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![0.0; 55]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
|
||
// All values should be finite (no NaN/Inf)
|
||
for (i, &val) in features.iter().enumerate() {
|
||
assert!(
|
||
val.is_finite(),
|
||
"Found non-finite value at index {}: {}",
|
||
i,
|
||
val
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_extreme_volume_clipping() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
let bars = create_bars_with_volume(vec![1_000_000.0; 55]);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
|
||
// All values should be within expected ranges
|
||
assert!(features[0] >= -2.0 && features[0] <= 5.0); // Volume ratio
|
||
assert!(features[1] >= -1.0 && features[1] <= 3.0); // ROC 5
|
||
assert!(features[2] >= -1.0 && features[2] <= 3.0); // ROC 10
|
||
assert!(features[3] >= -5.0 && features[3] <= 5.0); // Acceleration
|
||
assert!(features[4] >= -1.0 && features[4] <= 1.0); // Trend slope
|
||
assert!(features[5] >= -0.1 && features[5] <= 0.1); // VWAP deviation
|
||
assert!(features[6] >= -1.0 && features[6] <= 1.0); // Correlation
|
||
assert!(features[7] >= 0.0 && features[7] <= 1.0); // Percentile
|
||
assert!(features[8] >= 0.0 && features[8] <= 1.0); // HHI
|
||
assert!(features[9] >= -1.0 && features[9] <= 1.0); // Imbalance
|
||
}
|
||
|
||
#[test]
|
||
fn test_all_features_finite() {
|
||
let mut extractor = VolumeFeatureExtractor::new();
|
||
|
||
// Create diverse bars with varying volumes
|
||
let volumes = vec![
|
||
1000.0, 1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0,
|
||
1200.0, 800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0,
|
||
800.0, 1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0,
|
||
1500.0, 900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0,
|
||
900.0, 2000.0, 1100.0, 1300.0, 700.0, 1400.0, 1000.0, 1200.0, 800.0, 1500.0, 900.0,
|
||
];
|
||
let bars = create_bars_with_volume(volumes);
|
||
|
||
for bar in &bars {
|
||
extractor.update(bar);
|
||
}
|
||
|
||
let features = extractor.extract_features().unwrap();
|
||
|
||
// Validate all features are finite
|
||
for (i, &val) in features.iter().enumerate() {
|
||
assert!(
|
||
val.is_finite(),
|
||
"Feature {} is not finite: {}",
|
||
i + 256,
|
||
val
|
||
);
|
||
}
|
||
}
|
||
}
|