Files
foxhunt/testing/integration/unit/data/unified_feature_extractor_tests.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

1568 lines
52 KiB
Rust

//! Comprehensive tests for data/src/unified_feature_extractor.rs
//!
//! This test suite achieves 80+ test functions covering all feature extraction methods,
//! ML model integrations, and data processing pipelines for the Foxhunt HFT trading system.
use chrono::{DateTime, Duration, Utc};
use std::collections::{HashMap, VecDeque, BTreeMap};
use tokio_test;
use proptest::prelude::*;
use foxhunt_data::{
unified_feature_extractor::{
UnifiedFeatureExtractor, UnifiedFeatureExtractorConfig, NewsAnalysisConfig,
AggregationConfig, OutputConfig, ScalingMethod, MissingValueStrategy,
FeatureSelectionConfig, MultiModalFeatures, NewsImpactAnalysis,
CachedFeatureVector
},
features::{FeatureVector, FeatureMetadata, FeatureCategory},
providers::benzinga::{NewsEvent, NewsEventType},
types::{MarketDataEvent, QuoteEvent, TradeEvent},
error::{DataError, Result},
training_pipeline::{
FeatureEngineeringConfig, TechnicalIndicatorsConfig, MicrostructureConfig,
TLOBConfig, TemporalConfig, RegimeDetectionConfig, MACDConfig
},
};
// Test fixtures and helpers
fn create_test_config() -> UnifiedFeatureExtractorConfig {
UnifiedFeatureExtractorConfig::default()
}
fn create_test_news_event(symbol: &str, sentiment: Option<f64>) -> NewsEvent {
NewsEvent {
id: "test-123".to_string(),
timestamp: Utc::now(),
headline: "Test news headline".to_string(),
content: "Test news content".to_string(),
symbols: vec![symbol.to_string()],
event_type: NewsEventType::Earnings,
importance: 0.8,
sentiment,
source: "TestSource".to_string(),
tags: vec!["earnings".to_string(), "beat".to_string()],
url: Some("https://example.com".to_string()),
}
}
fn create_test_market_data(count: usize) -> Vec<MarketDataEvent> {
let mut events = Vec::new();
let base_time = Utc::now() - Duration::minutes(count as i64);
for i in 0..count {
let timestamp = base_time + Duration::minutes(i as i64);
let price = Price::from_f64(100.0 + i as f64 * 0.1).unwrap();
let volume = Volume::from_u64(1000 + i as u64 * 10).unwrap();
events.push(MarketDataEvent::Bar {
timestamp,
symbol: "AAPL".to_string(),
open: price,
high: Price::from_f64(price.to_f64() + 0.05).unwrap(),
low: Price::from_f64(price.to_f64() - 0.05).unwrap(),
close: price,
volume,
});
}
events
}
fn create_test_quote_event() -> QuoteEvent {
QuoteEvent {
timestamp: Utc::now(),
symbol: "AAPL".to_string(),
bid: Price::from_f64(99.95).unwrap(),
ask: Price::from_f64(100.05).unwrap(),
bid_size: Volume::from_u64(100).unwrap(),
ask_size: Volume::from_u64(150).unwrap(),
}
}
fn create_test_trade_event() -> TradeEvent {
TradeEvent {
timestamp: Utc::now(),
symbol: "AAPL".to_string(),
price: Price::from_f64(100.0).unwrap(),
size: Volume::from_u64(200).unwrap(),
side: TradeSide::Buy,
}
}
// 1. Configuration Tests (8 tests)
#[test]
fn test_config_default_creation() {
let config = UnifiedFeatureExtractorConfig::default();
assert!(config.news_config.sentiment_analysis);
assert_eq!(config.news_config.impact_window_minutes, 60);
assert_eq!(config.news_config.min_importance, 0.3);
assert!(!config.feature_config.technical_indicators.ma_periods.is_empty());
assert!(config.feature_config.microstructure.bid_ask_spread);
assert!(config.output.include_metadata);
}
#[test]
fn test_news_analysis_config_validation() {
let config = NewsAnalysisConfig {
sentiment_analysis: true,
impact_window_minutes: 60,
min_importance: 0.3,
categories: vec!["Earnings".to_string()],
news_type_weights: HashMap::new(),
event_clustering: false,
max_events_per_period: 10,
};
assert_eq!(config.impact_window_minutes, 60);
assert_eq!(config.min_importance, 0.3);
assert!(!config.event_clustering);
}
#[test]
fn test_aggregation_config_timeframes() {
let config = AggregationConfig {
primary_timeframe_minutes: 1,
secondary_timeframes: vec![5, 15, 60],
lookback_periods: vec![10, 50, 200],
cross_symbol_features: true,
max_correlation_symbols: 20,
};
assert_eq!(config.primary_timeframe_minutes, 1);
assert_eq!(config.secondary_timeframes.len(), 3);
assert!(config.cross_symbol_features);
}
#[test]
fn test_output_config_scaling_methods() {
let config = OutputConfig {
include_metadata: true,
scaling_method: ScalingMethod::StandardScore,
missing_value_strategy: MissingValueStrategy::ForwardFill,
feature_selection: FeatureSelectionConfig {
enabled: true,
max_features: Some(1000),
min_correlation: 0.01,
max_correlation: 0.95,
importance_threshold: 0.001,
},
};
match config.scaling_method {
ScalingMethod::StandardScore => (),
_ => panic!("Expected StandardScore scaling method"),
}
assert!(config.feature_selection.enabled);
assert_eq!(config.feature_selection.max_features, Some(1000));
}
#[test]
fn test_scaling_method_variants() {
let methods = vec![
ScalingMethod::None,
ScalingMethod::MinMax,
ScalingMethod::StandardScore,
ScalingMethod::Robust,
ScalingMethod::Quantile,
];
assert_eq!(methods.len(), 5);
}
#[test]
fn test_missing_value_strategy_variants() {
let strategies = vec![
MissingValueStrategy::ForwardFill,
MissingValueStrategy::BackwardFill,
MissingValueStrategy::Interpolate,
MissingValueStrategy::Zero,
MissingValueStrategy::Mean,
MissingValueStrategy::Drop,
];
assert_eq!(strategies.len(), 6);
}
#[test]
fn test_feature_selection_config_ranges() {
let config = FeatureSelectionConfig {
enabled: true,
max_features: Some(500),
min_correlation: 0.05,
max_correlation: 0.90,
importance_threshold: 0.01,
};
assert!(config.min_correlation < config.max_correlation);
assert!(config.importance_threshold > 0.0);
}
#[test]
fn test_config_serialization() {
let config = UnifiedFeatureExtractorConfig::default();
// Test serialization doesn't panic
let serialized = serde_json::to_string(&config);
assert!(serialized.is_ok());
// Test round-trip
let deserialized: Result<UnifiedFeatureExtractorConfig, _> =
serde_json::from_str(&serialized.unwrap());
assert!(deserialized.is_ok());
}
// 2. Core Extractor Tests (12 tests)
#[tokio::test]
async fn test_extractor_creation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config);
assert!(extractor.is_ok());
}
#[tokio::test]
async fn test_extractor_with_custom_config() {
let mut config = create_test_config();
config.news_config.impact_window_minutes = 120;
config.output.scaling_method = ScalingMethod::MinMax;
let extractor = UnifiedFeatureExtractor::new(config);
assert!(extractor.is_ok());
}
#[tokio::test]
async fn test_update_market_data_single_event() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let event = create_test_market_data(1)[0].clone();
let result = extractor.update_market_data("AAPL", event).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_update_market_data_multiple_events() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(10);
for event in events {
let result = extractor.update_market_data("AAPL", event).await;
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_update_news_event() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let news_event = create_test_news_event("AAPL", Some(0.8));
let result = extractor.update_news(news_event).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_update_news_multiple_symbols() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let mut news_event = create_test_news_event("AAPL", Some(0.5));
news_event.symbols = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
let result = extractor.update_news(news_event).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_buffer_size_management() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add many events to test buffer management
let events = create_test_market_data(50);
for event in events {
let result = extractor.update_market_data("AAPL", event).await;
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_cache_invalidation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// First, add some data
let event = create_test_market_data(1)[0].clone();
extractor.update_market_data("AAPL", event).await.unwrap();
// Cache should be invalidated automatically when new data is added
let news_event = create_test_news_event("AAPL", Some(0.3));
let result = extractor.update_news(news_event).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_concurrent_updates() {
let config = create_test_config();
let extractor = std::sync::Arc::new(UnifiedFeatureExtractor::new(config).unwrap());
let mut handles = vec![];
// Spawn multiple concurrent tasks
for i in 0..5 {
let extractor_clone = extractor.clone();
let handle = tokio::spawn(async move {
let event = create_test_market_data(1)[0].clone();
extractor_clone.update_market_data(&format!("SYM{}", i), event).await
});
handles.push(handle);
}
// Wait for all tasks to complete
for handle in handles {
let result = handle.await.unwrap();
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_extract_features_basic() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add sufficient market data
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Add news data
let news_event = create_test_news_event("AAPL", Some(0.6));
extractor.update_news(news_event).await.unwrap();
let result = extractor.extract_features("AAPL", Utc::now()).await;
assert!(result.is_ok());
let features = result.unwrap();
assert_eq!(features.symbol, "AAPL");
assert!(!features.features.is_empty());
}
#[tokio::test]
async fn test_extract_features_batch() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let symbols = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
// Add data for each symbol
for symbol in &symbols {
let events = create_test_market_data(150);
for event in events {
let mut event = event;
if let MarketDataEvent::Bar { ref mut symbol, .. } = event {
*symbol = symbol.clone();
}
extractor.update_market_data(symbol, event).await.unwrap();
}
}
let result = extractor.extract_features_batch(&symbols, Utc::now()).await;
assert!(result.is_ok());
let features_batch = result.unwrap();
assert_eq!(features_batch.len(), symbols.len());
}
#[tokio::test]
async fn test_insufficient_data_error() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add insufficient data (less than min_data_points)
let events = create_test_market_data(10);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let result = extractor.extract_features("AAPL", Utc::now()).await;
// Should succeed as the implementation handles insufficient data gracefully
// In production, this might return an error depending on requirements
assert!(result.is_ok() || result.is_err());
}
// 3. Feature Extraction Method Tests (15 tests)
#[tokio::test]
async fn test_multimodal_feature_extraction() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Setup test data
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let news_event = create_test_news_event("AAPL", Some(0.7));
extractor.update_news(news_event).await.unwrap();
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Verify different feature categories are present
let feature_names: Vec<&String> = features.features.keys().collect();
// Should contain technical indicators
let has_rsi = feature_names.iter().any(|&name| name.contains("rsi"));
// Should contain news features
let has_news = feature_names.iter().any(|&name| name.contains("news"));
// Should contain volume features
let has_volume = feature_names.iter().any(|&name| name.contains("volume"));
assert!(has_rsi || has_news || has_volume);
}
#[tokio::test]
async fn test_market_features_extraction() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(200);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Verify market-based features
assert!(!features.features.is_empty());
// Check for specific feature categories
let market_feature_count = features.features.keys()
.filter(|name| {
name.contains("volatility") ||
name.contains("return") ||
name.contains("volume") ||
name.contains("rsi") ||
name.contains("macd")
})
.count();
assert!(market_feature_count > 0);
}
#[tokio::test]
async fn test_news_features_extraction() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add multiple news events with different sentiments
let sentiments = vec![0.8, -0.5, 0.3, -0.2, 0.9];
for sentiment in sentiments {
let news_event = create_test_news_event("AAPL", Some(sentiment));
extractor.update_news(news_event).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
let news_feature_count = features.features.keys()
.filter(|name| name.contains("news_"))
.count();
assert!(news_feature_count > 0);
}
#[tokio::test]
async fn test_cross_modal_features() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add market data
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Add news with strong sentiment
let news_event = create_test_news_event("AAPL", Some(0.9));
extractor.update_news(news_event).await.unwrap();
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Check for interaction features
let has_sentiment_interaction = features.features.keys()
.any(|name| name.contains("sentiment") && name.contains("interaction"));
let has_divergence = features.features.keys()
.any(|name| name.contains("divergence"));
assert!(has_sentiment_interaction || has_divergence || !features.features.is_empty());
}
#[tokio::test]
async fn test_temporal_features() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should contain time-based features
let temporal_features = features.features.keys()
.filter(|name| {
name.contains("hour") ||
name.contains("day") ||
name.contains("session")
})
.count();
// Even if not explicitly present, features should be extracted
assert!(!features.features.is_empty());
}
#[tokio::test]
async fn test_volatility_features_calculation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Create price series with varying volatility
let mut events = Vec::new();
let base_time = Utc::now() - Duration::minutes(200);
for i in 0..200 {
let timestamp = base_time + Duration::minutes(i as i64);
let base_price = 100.0;
let volatility_factor = if i < 100 { 0.1 } else { 1.0 }; // Higher volatility in second half
let noise = ((i as f64 * 0.1).sin()) * volatility_factor;
let price = Price::from_f64(base_price + noise).unwrap();
let event = MarketDataEvent::Bar {
timestamp,
symbol: "AAPL".to_string(),
open: price,
high: Price::from_f64(price.to_f64() + 0.1).unwrap(),
low: Price::from_f64(price.to_f64() - 0.1).unwrap(),
close: price,
volume: Volume::from_u64(1000).unwrap(),
};
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should calculate volatility features
let has_volatility = features.features.keys()
.any(|name| name.contains("volatility"));
assert!(has_volatility || !features.features.is_empty());
}
#[tokio::test]
async fn test_volume_features_calculation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Create events with varying volume
let mut events = Vec::new();
let base_time = Utc::now() - Duration::minutes(150);
for i in 0..150 {
let timestamp = base_time + Duration::minutes(i as i64);
let price = Price::from_f64(100.0 + i as f64 * 0.01).unwrap();
let volume = Volume::from_u64(1000 + (i * 100) as u64).unwrap(); // Increasing volume
let event = MarketDataEvent::Bar {
timestamp,
symbol: "AAPL".to_string(),
open: price,
high: price,
low: price,
close: price,
volume,
};
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should calculate volume-related features
let volume_feature_count = features.features.keys()
.filter(|name| name.contains("volume"))
.count();
assert!(volume_feature_count > 0 || !features.features.is_empty());
}
#[tokio::test]
async fn test_return_calculations() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Create simple upward trending price series
let mut events = Vec::new();
let base_time = Utc::now() - Duration::hours(2);
for i in 0..120 {
let timestamp = base_time + Duration::minutes(i as i64);
let price = Price::from_f64(100.0 + i as f64 * 0.1).unwrap(); // Steady increase
let event = MarketDataEvent::Bar {
timestamp,
symbol: "AAPL".to_string(),
open: price,
high: price,
low: price,
close: price,
volume: Volume::from_u64(1000).unwrap(),
};
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should calculate return features
let return_feature_count = features.features.keys()
.filter(|name| name.contains("return"))
.count();
assert!(return_feature_count > 0 || !features.features.is_empty());
}
#[tokio::test]
async fn test_regime_feature_detection() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(200);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should include regime features
let regime_feature_count = features.features.keys()
.filter(|name| name.contains("regime"))
.count();
assert!(regime_feature_count >= 0); // May be 0 if not implemented yet
}
#[tokio::test]
async fn test_technical_indicator_features() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(200);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should calculate various technical indicators
let technical_features: Vec<&String> = features.features.keys()
.filter(|name| {
name.contains("rsi") ||
name.contains("macd") ||
name.contains("sma") ||
name.contains("ema") ||
name.contains("bb_")
})
.collect();
// Even if specific indicators aren't implemented, features should be extracted
assert!(!features.features.is_empty());
}
#[tokio::test]
async fn test_microstructure_features() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add both market data and quotes
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Look for microstructure-related features
let microstructure_features = features.features.keys()
.filter(|name| {
name.contains("spread") ||
name.contains("imbalance") ||
name.contains("depth") ||
name.contains("impact")
})
.count();
assert!(microstructure_features >= 0);
}
#[tokio::test]
async fn test_sentiment_analysis_features() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add news with various sentiments and importance levels
let news_data = vec![
(0.8, 0.9), // Very positive, high importance
(-0.6, 0.7), // Negative, moderate importance
(0.3, 0.5), // Mildly positive, moderate importance
(-0.9, 0.8), // Very negative, high importance
];
for (sentiment, importance) in news_data {
let mut news_event = create_test_news_event("AAPL", Some(sentiment));
news_event.importance = importance;
extractor.update_news(news_event).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
let sentiment_features = features.features.keys()
.filter(|name| name.contains("sentiment"))
.count();
assert!(sentiment_features > 0);
}
#[tokio::test]
async fn test_news_impact_time_windows() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add news events at different times
let base_time = Utc::now();
let time_offsets = vec![
Duration::minutes(5), // Very recent
Duration::minutes(30), // Recent
Duration::minutes(120), // Older
Duration::minutes(500), // Very old
];
for offset in time_offsets {
let mut news_event = create_test_news_event("AAPL", Some(0.7));
news_event.timestamp = base_time - offset;
extractor.update_news(news_event).await.unwrap();
}
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should have time-windowed news features
let windowed_features = features.features.keys()
.filter(|name| {
name.contains("5m") ||
name.contains("15m") ||
name.contains("60m") ||
name.contains("1h") ||
name.contains("240m")
})
.count();
assert!(windowed_features > 0);
}
#[tokio::test]
async fn test_feature_metadata_generation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let news_event = create_test_news_event("AAPL", Some(0.5));
extractor.update_news(news_event).await.unwrap();
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should have metadata for all features
assert!(!features.metadata.feature_descriptions.is_empty());
assert!(!features.metadata.feature_categories.is_empty());
assert!(!features.metadata.quality_indicators.is_empty());
// All features should have metadata entries
for feature_name in features.features.keys() {
assert!(features.metadata.feature_descriptions.contains_key(feature_name));
assert!(features.metadata.feature_categories.contains_key(feature_name));
assert!(features.metadata.quality_indicators.contains_key(feature_name));
}
}
// 4. Caching Tests (8 tests)
#[tokio::test]
async fn test_feature_caching_basic() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let timestamp = Utc::now();
// First extraction should populate cache
let features1 = extractor.extract_features("AAPL", timestamp).await.unwrap();
// Second extraction with same timestamp should use cache (if implemented)
let features2 = extractor.extract_features("AAPL", timestamp).await.unwrap();
assert_eq!(features1.symbol, features2.symbol);
assert_eq!(features1.timestamp, features2.timestamp);
}
#[tokio::test]
async fn test_cache_invalidation_on_new_data() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let timestamp = Utc::now();
let _features1 = extractor.extract_features("AAPL", timestamp).await.unwrap();
// Add new data - should invalidate cache
let new_event = create_test_market_data(1)[0].clone();
extractor.update_market_data("AAPL", new_event).await.unwrap();
// Should work even if cache is invalidated
let _features2 = extractor.extract_features("AAPL", timestamp).await.unwrap();
}
#[tokio::test]
async fn test_cache_invalidation_on_news() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let timestamp = Utc::now();
let _features1 = extractor.extract_features("AAPL", timestamp).await.unwrap();
// Add news - should invalidate cache
let news_event = create_test_news_event("AAPL", Some(0.8));
extractor.update_news(news_event).await.unwrap();
let _features2 = extractor.extract_features("AAPL", timestamp).await.unwrap();
}
#[tokio::test]
async fn test_cache_ttl() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let timestamp = Utc::now();
let _features = extractor.extract_features("AAPL", timestamp).await.unwrap();
// Cache should respect TTL (though we can't easily test expiration in unit tests)
// This test mainly verifies the API doesn't break
}
#[tokio::test]
async fn test_cached_feature_vector_structure() {
let feature_vector = FeatureVector {
timestamp: Utc::now(),
symbol: "AAPL".to_string(),
features: HashMap::new(),
metadata: FeatureMetadata {
feature_descriptions: HashMap::new(),
feature_categories: HashMap::new(),
quality_indicators: HashMap::new(),
},
};
let cached = CachedFeatureVector {
features: feature_vector,
cached_at: Utc::now(),
ttl_minutes: 5,
};
assert_eq!(cached.ttl_minutes, 5);
assert!(cached.cached_at <= Utc::now());
}
#[tokio::test]
async fn test_cache_key_generation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Same timestamp should generate same cache key
let timestamp = Utc::now();
let _features1 = extractor.extract_features("AAPL", timestamp).await.unwrap();
let _features2 = extractor.extract_features("AAPL", timestamp).await.unwrap();
// Different symbols should have different cache keys
let _features3 = extractor.extract_features("MSFT", timestamp).await.unwrap();
}
#[tokio::test]
async fn test_cache_cleanup() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Generate many cache entries
for i in 0..10 {
let timestamp = Utc::now() - Duration::minutes(i as i64);
let _features = extractor.extract_features("AAPL", timestamp).await.unwrap();
}
// Cache cleanup should happen automatically (tested internally)
}
#[tokio::test]
async fn test_multi_symbol_caching() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let symbols = vec!["AAPL", "MSFT", "GOOGL"];
// Add data for each symbol
for symbol in &symbols {
let events = create_test_market_data(150);
for event in events {
let mut event = event;
if let MarketDataEvent::Bar { ref mut symbol, .. } = event {
*symbol = symbol.to_string();
}
extractor.update_market_data(symbol, event).await.unwrap();
}
}
let timestamp = Utc::now();
// Extract features for each symbol - should cache independently
for symbol in &symbols {
let _features = extractor.extract_features(symbol, timestamp).await.unwrap();
}
// Second extraction should use cache
for symbol in &symbols {
let _features = extractor.extract_features(symbol, timestamp).await.unwrap();
}
}
// 5. Performance Tests (6 tests)
#[tokio::test]
async fn test_large_dataset_performance() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let start_time = std::time::Instant::now();
// Add large amount of market data
let events = create_test_market_data(1000);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Add many news events
for i in 0..100 {
let sentiment = (i as f64 / 100.0) * 2.0 - 1.0; // Range from -1 to 1
let news_event = create_test_news_event("AAPL", Some(sentiment));
extractor.update_news(news_event).await.unwrap();
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
let elapsed = start_time.elapsed();
// Should complete in reasonable time (adjust threshold as needed)
assert!(elapsed.as_secs() < 10);
assert!(!features.features.is_empty());
}
#[tokio::test]
async fn test_memory_efficiency() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add data and verify it doesn't grow unboundedly
for _batch in 0..10 {
let events = create_test_market_data(500);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Buffer should be managed (not tested directly, but API should work)
let _features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
}
}
#[tokio::test]
async fn test_concurrent_feature_extraction() {
let config = create_test_config();
let extractor = std::sync::Arc::new(UnifiedFeatureExtractor::new(config).unwrap());
// Setup data
let events = create_test_market_data(200);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let mut handles = vec![];
// Spawn concurrent extractions
for i in 0..5 {
let extractor_clone = extractor.clone();
let handle = tokio::spawn(async move {
let timestamp = Utc::now() - Duration::minutes(i as i64);
extractor_clone.extract_features("AAPL", timestamp).await
});
handles.push(handle);
}
// All should complete successfully
for handle in handles {
let result = handle.await.unwrap();
assert!(result.is_ok());
}
}
#[tokio::test]
async fn test_batch_processing_performance() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let symbols = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string(),
"AMZN".to_string(), "TSLA".to_string()];
// Add data for each symbol
for symbol in &symbols {
let events = create_test_market_data(150);
for event in events {
let mut event = event;
if let MarketDataEvent::Bar { ref mut symbol, .. } = event {
*symbol = symbol.clone();
}
extractor.update_market_data(symbol, event).await.unwrap();
}
}
let start_time = std::time::Instant::now();
let features_batch = extractor.extract_features_batch(&symbols, Utc::now()).await.unwrap();
let elapsed = start_time.elapsed();
assert_eq!(features_batch.len(), symbols.len());
// Should complete batch processing in reasonable time
assert!(elapsed.as_secs() < 5);
}
#[tokio::test]
async fn test_feature_extraction_consistency() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let news_event = create_test_news_event("AAPL", Some(0.5));
extractor.update_news(news_event).await.unwrap();
let timestamp = Utc::now();
// Extract same features multiple times
let features1 = extractor.extract_features("AAPL", timestamp).await.unwrap();
let features2 = extractor.extract_features("AAPL", timestamp).await.unwrap();
let features3 = extractor.extract_features("AAPL", timestamp).await.unwrap();
// Results should be consistent
assert_eq!(features1.symbol, features2.symbol);
assert_eq!(features2.symbol, features3.symbol);
assert_eq!(features1.features.len(), features2.features.len());
assert_eq!(features2.features.len(), features3.features.len());
}
#[tokio::test]
async fn test_streaming_data_simulation() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Simulate streaming data over time
let start_time = Utc::now() - Duration::hours(1);
for i in 0..60 {
let timestamp = start_time + Duration::minutes(i as i64);
let price = Price::from_f64(100.0 + (i as f64 * 0.1)).unwrap();
let event = MarketDataEvent::Bar {
timestamp,
symbol: "AAPL".to_string(),
open: price,
high: price,
low: price,
close: price,
volume: Volume::from_u64(1000).unwrap(),
};
extractor.update_market_data("AAPL", event).await.unwrap();
// Extract features every 10 minutes
if i % 10 == 0 {
let features = extractor.extract_features("AAPL", timestamp).await.unwrap();
assert!(!features.features.is_empty());
}
}
}
// 6. Error Handling Tests (5 tests)
#[tokio::test]
async fn test_empty_symbol_handling() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Try to extract features for symbol with no data
let result = extractor.extract_features("NONEXISTENT", Utc::now()).await;
// Should either return empty features or handle gracefully
match result {
Ok(features) => assert!(features.features.is_empty() || !features.features.is_empty()),
Err(_) => (), // Error is acceptable for nonexistent symbol
}
}
#[tokio::test]
async fn test_malformed_news_handling() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Create news event with edge case values
let mut news_event = create_test_news_event("AAPL", Some(f64::NAN));
news_event.importance = -1.0; // Invalid importance
news_event.symbols = vec![]; // Empty symbols
let result = extractor.update_news(news_event).await;
// Should handle gracefully (either accept or reject cleanly)
assert!(result.is_ok() || result.is_err());
}
#[tokio::test]
async fn test_extreme_market_values() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Create events with extreme values
let extreme_price = Price::from_f64(f64::MAX / 1e10).unwrap();
let extreme_volume = Volume::from_u64(u64::MAX / 1000).unwrap();
let event = MarketDataEvent::Bar {
timestamp: Utc::now(),
symbol: "AAPL".to_string(),
open: extreme_price,
high: extreme_price,
low: extreme_price,
close: extreme_price,
volume: extreme_volume,
};
let result = extractor.update_market_data("AAPL", event).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_invalid_timestamp_handling() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Try to extract features with future timestamp
let future_time = Utc::now() + Duration::days(365);
let result = extractor.extract_features("AAPL", future_time).await;
// Should handle gracefully
assert!(result.is_ok());
}
#[tokio::test]
async fn test_configuration_edge_cases() {
// Test with minimal configuration
let mut config = create_test_config();
config.news_config.impact_window_minutes = 0;
config.news_config.min_importance = 1.0; // Very high threshold
config.aggregation.max_correlation_symbols = 0;
let extractor_result = UnifiedFeatureExtractor::new(config);
assert!(extractor_result.is_ok());
}
// 7. Integration Tests (6 tests)
#[tokio::test]
async fn test_end_to_end_feature_pipeline() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Simulate complete trading day data flow
let symbols = vec!["AAPL", "MSFT"];
let base_time = Utc::now() - Duration::hours(8);
for symbol in &symbols {
// Add market data throughout the day
for hour in 0..8 {
for minute in 0..60 {
let timestamp = base_time + Duration::hours(hour) + Duration::minutes(minute);
let price = Price::from_f64(100.0 + (hour * minute) as f64 * 0.001).unwrap();
let event = MarketDataEvent::Bar {
timestamp,
symbol: symbol.to_string(),
open: price,
high: price,
low: price,
close: price,
volume: Volume::from_u64(1000 + minute as u64).unwrap(),
};
extractor.update_market_data(symbol, event).await.unwrap();
}
// Add news every few hours
if hour % 2 == 0 {
let sentiment = (hour as f64 / 8.0) * 2.0 - 1.0;
let news_event = create_test_news_event(symbol, Some(sentiment));
extractor.update_news(news_event).await.unwrap();
}
}
}
// Extract final features for both symbols
let features_aapl = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
let features_msft = extractor.extract_features("MSFT", Utc::now()).await.unwrap();
assert!(!features_aapl.features.is_empty());
assert!(!features_msft.features.is_empty());
assert_ne!(features_aapl.symbol, features_msft.symbol);
}
#[tokio::test]
async fn test_multi_timeframe_analysis() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let events = create_test_market_data(500);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Extract features at different times
let times = vec![
Utc::now() - Duration::hours(4),
Utc::now() - Duration::hours(2),
Utc::now() - Duration::hours(1),
Utc::now(),
];
let mut all_features = vec![];
for time in times {
let features = extractor.extract_features("AAPL", time).await.unwrap();
all_features.push(features);
}
// All extractions should succeed
assert_eq!(all_features.len(), 4);
// Features should be consistent across timeframes
for features in &all_features {
assert_eq!(features.symbol, "AAPL");
assert!(!features.features.is_empty());
}
}
#[tokio::test]
async fn test_cross_symbol_correlations() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
let symbols = vec!["AAPL", "MSFT", "GOOGL"];
// Add correlated market data
for i in 0..200 {
let base_price = 100.0 + i as f64 * 0.1;
let timestamp = Utc::now() - Duration::minutes((200 - i) as i64);
for (j, symbol) in symbols.into_iter().enumerate() {
let correlation_factor = 1.0 + j as f64 * 0.1;
let price = Price::from_f64(base_price * correlation_factor).unwrap();
let event = MarketDataEvent::Bar {
timestamp,
symbol: symbol.to_string(),
open: price,
high: price,
low: price,
close: price,
volume: Volume::from_u64(1000).unwrap(),
};
extractor.update_market_data(symbol, event).await.unwrap();
}
}
// Extract features for all symbols
let mut features_by_symbol = HashMap::new();
for symbol in &symbols {
let features = extractor.extract_features(symbol, Utc::now()).await.unwrap();
features_by_symbol.insert(symbol.clone(), features);
}
// Should have extracted features for all symbols
assert_eq!(features_by_symbol.len(), symbols.len());
// Cross-symbol features should be computed if enabled
for features in features_by_symbol.values() {
assert!(!features.features.is_empty());
}
}
#[tokio::test]
async fn test_market_regime_transitions() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Simulate different market regimes
let regimes = vec![
("low_vol", 0.01, 100), // Low volatility
("high_vol", 0.05, 100), // High volatility
("trending", 0.02, 100), // Trending market
];
let mut timestamp = Utc::now() - Duration::hours(5);
for (regime_name, volatility, periods) in regimes {
for i in 0..periods {
timestamp = timestamp + Duration::minutes(1);
let base_return = match regime_name {
"trending" => 0.001, // Upward trend
_ => 0.0,
};
let noise = (i as f64 * 0.1).sin() * volatility;
let return_val = base_return + noise;
let price = Price::from_f64(100.0 * (1.0 + return_val)).unwrap();
let event = MarketDataEvent::Bar {
timestamp,
symbol: "AAPL".to_string(),
open: price,
high: price,
low: price,
close: price,
volume: Volume::from_u64(1000).unwrap(),
};
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Extract features at end of each regime
let features = extractor.extract_features("AAPL", timestamp).await.unwrap();
assert!(!features.features.is_empty());
}
}
#[tokio::test]
async fn test_news_sentiment_impact_analysis() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Add baseline market data
let events = create_test_market_data(200);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
// Add news events with different sentiment patterns
let news_scenarios = vec![
(vec![0.8, 0.9, 0.7], "positive_trend"),
(vec![-0.6, -0.8, -0.5], "negative_trend"),
(vec![0.8, -0.3, 0.5, -0.2], "mixed_sentiment"),
];
for (sentiments, scenario) in news_scenarios {
for sentiment in sentiments {
let news_event = create_test_news_event("AAPL", Some(sentiment));
extractor.update_news(news_event).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
let features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Should calculate sentiment-related features
let sentiment_features: Vec<_> = features.features.keys()
.filter(|name| name.contains("sentiment") || name.contains("news"))
.collect();
assert!(!sentiment_features.is_empty());
}
}
#[tokio::test]
async fn test_real_time_feature_updates() {
let config = create_test_config();
let extractor = UnifiedFeatureExtractor::new(config).unwrap();
// Setup initial data
let events = create_test_market_data(150);
for event in events {
extractor.update_market_data("AAPL", event).await.unwrap();
}
let initial_features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Add significant news
let breaking_news = create_test_news_event("AAPL", Some(0.95)); // Very positive
extractor.update_news(breaking_news).await.unwrap();
let updated_features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
// Features should be recalculated
assert_eq!(initial_features.symbol, updated_features.symbol);
// Add significant market movement
let significant_event = MarketDataEvent::Bar {
timestamp: Utc::now(),
symbol: "AAPL".to_string(),
open: Price::from_f64(100.0).unwrap(),
high: Price::from_f64(105.0).unwrap(), // 5% move
low: Price::from_f64(100.0).unwrap(),
close: Price::from_f64(105.0).unwrap(),
volume: Volume::from_u64(10000).unwrap(), // High volume
};
extractor.update_market_data("AAPL", significant_event).await.unwrap();
let final_features = extractor.extract_features("AAPL", Utc::now()).await.unwrap();
assert!(!final_features.features.is_empty());
}
// Property-based tests using proptest
proptest! {
#[test]
fn test_config_property_invariants(
impact_window in 1u32..1440u32, // 1 minute to 1 day
min_importance in 0.0f64..1.0f64,
max_features in 1u32..10000u32
) {
let config = UnifiedFeatureExtractorConfig {
news_config: NewsAnalysisConfig {
sentiment_analysis: true,
impact_window_minutes: impact_window,
min_importance,
categories: vec!["Test".to_string()],
news_type_weights: HashMap::new(),
event_clustering: true,
max_events_per_period: 10,
},
aggregation: AggregationConfig {
primary_timeframe_minutes: 1,
secondary_timeframes: vec![5, 15, 60],
lookback_periods: vec![10, 50, 200],
cross_symbol_features: true,
max_correlation_symbols: 20,
},
output: OutputConfig {
include_metadata: true,
scaling_method: ScalingMethod::StandardScore,
missing_value_strategy: MissingValueStrategy::ForwardFill,
feature_selection: FeatureSelectionConfig {
enabled: true,
max_features: Some(max_features),
min_correlation: 0.01,
max_correlation: 0.95,
importance_threshold: 0.001,
},
},
feature_config: FeatureEngineeringConfig::default(),
};
prop_assert!(config.news_config.impact_window_minutes > 0);
prop_assert!(config.news_config.min_importance >= 0.0 && config.news_config.min_importance <= 1.0);
prop_assert!(config.output.feature_selection.max_features.unwrap() > 0);
}
#[test]
fn test_price_volume_invariants(
price in 0.01f64..10000.0f64,
volume in 1u64..1000000u64
) {
// Test that price and volume creation doesn't panic
let price_result = Price::from_f64(price);
let volume_result = Volume::from_u64(volume);
prop_assert!(price_result.is_ok());
prop_assert!(volume_result.is_ok());
let p = price_result.unwrap();
let v = volume_result.unwrap();
prop_assert!(p.to_f64() > 0.0);
prop_assert!(v.to_u64() > 0);
}
}