Add #![deny(clippy::unwrap_used, clippy::expect_used)] to lib.rs and main.rs. Fix all violations by category: - training_metrics.rs / simple_metrics.rs: file-level #![allow] with safety comment (Prometheus register_*!() macros with literal names are infallible) - asset_parser.rs: function-level #[allow] for invariant regex literal expect() - technical_indicators.rs: replace unwrap() on VecDeque::back()/get() with let-else early returns - data_config.rs: bind start/end before assigning to avoid unwrap() - data_loader.rs: convert 3x database.as_ref().expect() to .ok_or_else()?; fix Price construction chain with .or_else().map_err()? - dbn_data_loader.rs: fix Price::from_f64().unwrap_or_else() chains with .or_else().unwrap_or_default() - checkpoint_manager.rs: convert serde_json::to_value().unwrap() to .map_err()? - orchestrator.rs: use unwrap_or_default() for Price in map() closures - main.rs: fix rustls expect, metrics encoder, spawn closure error handling - validation_pipeline.rs: fix path UTF-8 expect and last().expect() calls - batch_tuning_manager.rs: fix current_dir().expect() with unwrap_or_else - All test modules: add #[allow(clippy::unwrap_used, clippy::expect_used)] Result: ml_training_service generates zero clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
580 lines
17 KiB
Rust
580 lines
17 KiB
Rust
//! Training Data Source Configuration
|
|
//!
|
|
//! Defines configuration for ML training data sources to replace mock data generator.
|
|
//! This is Phase 1 of the 6-phase ML Training Data Pipeline implementation.
|
|
//!
|
|
//! ## Data Source Types
|
|
//!
|
|
//! - **Historical**: Load data from PostgreSQL database (backtests, historical trades)
|
|
//! - **RealTime**: Stream data from live trading (requires active trading session)
|
|
//! - **Hybrid**: Combine historical baseline with recent real-time data
|
|
//! - **Parquet**: Load from S3 parquet files (feature engineered datasets)
|
|
//!
|
|
//! ## Configuration Sources
|
|
//!
|
|
//! 1. Environment variables (runtime override)
|
|
//! 2. PostgreSQL config table (persistent settings)
|
|
//! 3. Default values (fallback)
|
|
|
|
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Training data source type
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum DataSourceType {
|
|
/// Load from Postgre`SQL` historical data tables
|
|
Historical,
|
|
/// Stream from live trading system (requires active session)
|
|
RealTime,
|
|
/// Combine historical baseline with recent real-time data
|
|
Hybrid,
|
|
/// Load from S3 parquet files (pre-processed features)
|
|
Parquet,
|
|
}
|
|
|
|
impl Default for DataSourceType {
|
|
fn default() -> Self {
|
|
Self::Historical
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for DataSourceType {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(s: &str) -> Result<Self> {
|
|
match s.to_lowercase().as_str() {
|
|
"historical" => Ok(Self::Historical),
|
|
"realtime" | "real-time" | "real_time" => Ok(Self::RealTime),
|
|
"hybrid" => Ok(Self::Hybrid),
|
|
"parquet" => Ok(Self::Parquet),
|
|
_ => Err(anyhow::anyhow!(
|
|
"Invalid data source type '{}'. Expected: historical, realtime, hybrid, or parquet",
|
|
s
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Complete training data source configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TrainingDataSourceConfig {
|
|
/// Data source type
|
|
pub source_type: DataSourceType,
|
|
|
|
/// Database connection settings (for Historical/Hybrid sources)
|
|
pub database: Option<DatabaseConfig>,
|
|
|
|
/// S3 configuration (for Parquet source)
|
|
pub s3: Option<S3Config>,
|
|
|
|
/// Time range for data loading
|
|
pub time_range: TimeRangeConfig,
|
|
|
|
/// Symbol filters (empty = all symbols)
|
|
pub symbols: Vec<String>,
|
|
|
|
/// Feature extraction settings
|
|
pub features: FeatureExtractionConfig,
|
|
|
|
/// Data validation settings
|
|
pub validation: DataValidationConfig,
|
|
|
|
/// Caching settings
|
|
pub cache: CacheConfig,
|
|
}
|
|
|
|
/// Database connection configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DatabaseConfig {
|
|
/// Postgre`SQL` connection URL (from env or config)
|
|
pub connection_url: String,
|
|
|
|
/// Maximum connection pool size
|
|
pub max_connections: u32,
|
|
|
|
/// Query timeout (seconds)
|
|
pub query_timeout_secs: u64,
|
|
|
|
/// Tables to query
|
|
pub tables: DatabaseTables,
|
|
}
|
|
|
|
/// Database table names for training data
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DatabaseTables {
|
|
/// `Order` book snapshots table
|
|
pub order_books: String,
|
|
|
|
/// `Trade` executions table
|
|
pub trades: String,
|
|
|
|
/// Market data events table
|
|
pub market_data: String,
|
|
|
|
/// Feature cache table (if available)
|
|
pub feature_cache: Option<String>,
|
|
}
|
|
|
|
impl Default for DatabaseTables {
|
|
fn default() -> Self {
|
|
Self {
|
|
order_books: "order_book_snapshots".to_string(),
|
|
trades: "trade_executions".to_string(),
|
|
market_data: "market_events".to_string(),
|
|
feature_cache: Some("ml_feature_cache".to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// S3 storage configuration for parquet files
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct S3Config {
|
|
/// S3 bucket name
|
|
pub bucket: String,
|
|
|
|
/// AWS region
|
|
pub region: String,
|
|
|
|
/// S3 path prefix (e.g., "training-data/features/")
|
|
pub path_prefix: String,
|
|
|
|
/// File pattern (e.g., "features-{date}.parquet")
|
|
pub file_pattern: String,
|
|
|
|
/// AWS credentials source (iam_role, env_vars, or profile)
|
|
pub credentials_source: String,
|
|
}
|
|
|
|
/// Time range configuration for data loading
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TimeRangeConfig {
|
|
/// Start time (None = earliest available)
|
|
pub start: Option<DateTime<Utc>>,
|
|
|
|
/// End time (None = most recent)
|
|
pub end: Option<DateTime<Utc>>,
|
|
|
|
/// Duration (alternative to start/end)
|
|
pub duration_days: Option<i64>,
|
|
|
|
/// Training/validation split ratio (0.0-1.0)
|
|
pub train_split: f64,
|
|
}
|
|
|
|
impl Default for TimeRangeConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
start: None,
|
|
end: None,
|
|
duration_days: Some(30), // Default: last 30 days
|
|
train_split: 0.8, // 80% training, 20% validation
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Feature extraction configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FeatureExtractionConfig {
|
|
/// Technical indicators to compute
|
|
pub technical_indicators: Vec<String>,
|
|
|
|
/// Microstructure features to extract
|
|
pub microstructure_features: Vec<String>,
|
|
|
|
/// Time-based aggregation windows (seconds)
|
|
pub aggregation_windows: Vec<u64>,
|
|
|
|
/// Enable TLOB (Temporal Limit `Order` Book) features
|
|
pub enable_tlob: bool,
|
|
|
|
/// Enable regime detection features
|
|
pub enable_regime_detection: bool,
|
|
|
|
/// Normalization method (zscore, minmax, none)
|
|
pub normalization: String,
|
|
}
|
|
|
|
impl Default for FeatureExtractionConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
technical_indicators: vec![
|
|
"rsi".to_string(),
|
|
"macd".to_string(),
|
|
"ema_fast".to_string(),
|
|
"ema_slow".to_string(),
|
|
],
|
|
microstructure_features: vec![
|
|
"spread_bps".to_string(),
|
|
"imbalance".to_string(),
|
|
"vwap".to_string(),
|
|
],
|
|
aggregation_windows: vec![60, 300, 900], // 1min, 5min, 15min
|
|
enable_tlob: true,
|
|
enable_regime_detection: true,
|
|
normalization: "zscore".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Data validation configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DataValidationConfig {
|
|
/// Minimum data points required
|
|
pub min_samples: usize,
|
|
|
|
/// Maximum missing data ratio (0.0-1.0)
|
|
pub max_missing_ratio: f64,
|
|
|
|
/// Enable outlier detection
|
|
pub enable_outlier_detection: bool,
|
|
|
|
/// Outlier detection threshold (z-score)
|
|
pub outlier_threshold: f64,
|
|
}
|
|
|
|
impl Default for DataValidationConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
min_samples: 1000,
|
|
max_missing_ratio: 0.1, // Max 10% missing data
|
|
enable_outlier_detection: true,
|
|
outlier_threshold: 3.0, // 3 standard deviations
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Caching configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CacheConfig {
|
|
/// Enable local disk caching
|
|
pub enable_disk_cache: bool,
|
|
|
|
/// Cache directory path
|
|
pub cache_dir: String,
|
|
|
|
/// Cache TTL in hours
|
|
pub ttl_hours: u64,
|
|
|
|
/// Maximum cache size in MB
|
|
pub max_size_mb: u64,
|
|
}
|
|
|
|
impl Default for CacheConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enable_disk_cache: true,
|
|
cache_dir: "/tmp/foxhunt-training-cache".to_string(),
|
|
ttl_hours: 24,
|
|
max_size_mb: 10240, // 10GB
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TrainingDataSourceConfig {
|
|
/// Load configuration from environment variables and defaults
|
|
pub fn from_env() -> Result<Self> {
|
|
let source_type = env::var("DATA_SOURCE_TYPE")
|
|
.unwrap_or_else(|_| "historical".to_string())
|
|
.parse()
|
|
.context("Invalid DATA_SOURCE_TYPE")?;
|
|
|
|
info!("Configuring training data source: {:?}", source_type);
|
|
|
|
let database = if matches!(
|
|
source_type,
|
|
DataSourceType::Historical | DataSourceType::Hybrid
|
|
) {
|
|
Some(Self::load_database_config()?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let s3 = if source_type == DataSourceType::Parquet {
|
|
Some(Self::load_s3_config()?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let time_range = Self::load_time_range_config()?;
|
|
let symbols = Self::load_symbols_filter();
|
|
let features = Self::load_feature_config();
|
|
let validation = DataValidationConfig::default();
|
|
let cache = CacheConfig::default();
|
|
|
|
Ok(Self {
|
|
source_type,
|
|
database,
|
|
s3,
|
|
time_range,
|
|
symbols,
|
|
features,
|
|
validation,
|
|
cache,
|
|
})
|
|
}
|
|
|
|
/// Load database configuration from environment
|
|
fn load_database_config() -> Result<DatabaseConfig> {
|
|
let connection_url = env::var("DATABASE_URL")
|
|
.context("DATABASE_URL must be set for Historical/Hybrid data sources")?;
|
|
|
|
let max_connections = env::var("DB_MAX_CONNECTIONS")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(10);
|
|
|
|
let query_timeout_secs = env::var("DB_QUERY_TIMEOUT_SECS")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(300); // 5 minutes default
|
|
|
|
debug!(
|
|
"Database config: {} connections, {}s timeout",
|
|
max_connections, query_timeout_secs
|
|
);
|
|
|
|
Ok(DatabaseConfig {
|
|
connection_url,
|
|
max_connections,
|
|
query_timeout_secs,
|
|
tables: DatabaseTables::default(),
|
|
})
|
|
}
|
|
|
|
/// Load S3 configuration from environment
|
|
fn load_s3_config() -> Result<S3Config> {
|
|
let bucket =
|
|
env::var("S3_BUCKET").context("S3_BUCKET must be set for Parquet data source")?;
|
|
|
|
let region = env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
|
|
|
let path_prefix =
|
|
env::var("S3_PATH_PREFIX").unwrap_or_else(|_| "training-data/features/".to_string());
|
|
|
|
let file_pattern =
|
|
env::var("S3_FILE_PATTERN").unwrap_or_else(|_| "features-*.parquet".to_string());
|
|
|
|
let credentials_source =
|
|
env::var("AWS_CREDENTIALS_SOURCE").unwrap_or_else(|_| "iam_role".to_string());
|
|
|
|
debug!("S3 config: s3://{}/{}", bucket, path_prefix);
|
|
|
|
Ok(S3Config {
|
|
bucket,
|
|
region,
|
|
path_prefix,
|
|
file_pattern,
|
|
credentials_source,
|
|
})
|
|
}
|
|
|
|
/// Load time range configuration from environment
|
|
fn load_time_range_config() -> Result<TimeRangeConfig> {
|
|
let duration_days = env::var("DATA_DURATION_DAYS")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok());
|
|
|
|
let train_split = env::var("TRAIN_SPLIT")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(0.8);
|
|
|
|
let mut config = TimeRangeConfig {
|
|
start: None,
|
|
end: None,
|
|
duration_days,
|
|
train_split,
|
|
};
|
|
|
|
// If duration specified, calculate start/end
|
|
if let Some(days) = duration_days {
|
|
let end = Utc::now();
|
|
let start = Utc::now() - Duration::days(days);
|
|
config.end = Some(end);
|
|
config.start = Some(start);
|
|
debug!(
|
|
"Time range: {} days ({} to {})",
|
|
days,
|
|
start,
|
|
end
|
|
);
|
|
}
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Load symbols filter from environment
|
|
fn load_symbols_filter() -> Vec<String> {
|
|
env::var("TRAINING_SYMBOLS")
|
|
.map(|s| {
|
|
s.split(',')
|
|
.map(|sym| sym.trim().to_uppercase())
|
|
.filter(|sym| !sym.is_empty())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Load feature extraction configuration
|
|
fn load_feature_config() -> FeatureExtractionConfig {
|
|
let mut config = FeatureExtractionConfig::default();
|
|
|
|
if let Ok(indicators) = env::var("FEATURE_TECHNICAL_INDICATORS") {
|
|
config.technical_indicators = indicators
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.collect();
|
|
}
|
|
|
|
if let Ok(enable_tlob) = env::var("FEATURE_ENABLE_TLOB") {
|
|
config.enable_tlob = enable_tlob.parse().unwrap_or(true);
|
|
}
|
|
|
|
if let Ok(normalization) = env::var("FEATURE_NORMALIZATION") {
|
|
config.normalization = normalization;
|
|
}
|
|
|
|
debug!(
|
|
"Feature config: {:?} indicators, TLOB={}, normalization={}",
|
|
config.technical_indicators.len(),
|
|
config.enable_tlob,
|
|
config.normalization
|
|
);
|
|
|
|
config
|
|
}
|
|
|
|
/// Validate configuration
|
|
pub fn validate(&self) -> Result<()> {
|
|
match self.source_type {
|
|
DataSourceType::Historical | DataSourceType::Hybrid => {
|
|
if self.database.is_none() {
|
|
anyhow::bail!(
|
|
"Database configuration required for {:?} source",
|
|
self.source_type
|
|
);
|
|
}
|
|
},
|
|
DataSourceType::Parquet => {
|
|
if self.s3.is_none() {
|
|
anyhow::bail!("S3 configuration required for Parquet source");
|
|
}
|
|
},
|
|
DataSourceType::RealTime => {
|
|
warn!("RealTime data source requires active trading session");
|
|
},
|
|
}
|
|
|
|
if self.time_range.train_split < 0.0 || self.time_range.train_split > 1.0 {
|
|
anyhow::bail!(
|
|
"Invalid train_split: {} (must be 0.0-1.0)",
|
|
self.time_range.train_split
|
|
);
|
|
}
|
|
|
|
if self.validation.max_missing_ratio < 0.0 || self.validation.max_missing_ratio > 1.0 {
|
|
anyhow::bail!(
|
|
"Invalid max_missing_ratio: {} (must be 0.0-1.0)",
|
|
self.validation.max_missing_ratio
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get summary for logging
|
|
pub fn summary(&self) -> HashMap<String, String> {
|
|
let mut summary = HashMap::new();
|
|
summary.insert("source_type".to_string(), format!("{:?}", self.source_type));
|
|
summary.insert("symbols_count".to_string(), self.symbols.len().to_string());
|
|
summary.insert(
|
|
"train_split".to_string(),
|
|
self.time_range.train_split.to_string(),
|
|
);
|
|
|
|
if let Some(days) = self.time_range.duration_days {
|
|
summary.insert("duration_days".to_string(), days.to_string());
|
|
}
|
|
|
|
summary.insert(
|
|
"features".to_string(),
|
|
format!(
|
|
"{}+{} (TLOB={})",
|
|
self.features.technical_indicators.len(),
|
|
self.features.microstructure_features.len(),
|
|
self.features.enable_tlob
|
|
),
|
|
);
|
|
|
|
summary
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_data_source_type_parsing() {
|
|
assert_eq!(
|
|
"historical".parse::<DataSourceType>().unwrap(),
|
|
DataSourceType::Historical
|
|
);
|
|
assert_eq!(
|
|
"REALTIME".parse::<DataSourceType>().unwrap(),
|
|
DataSourceType::RealTime
|
|
);
|
|
assert_eq!(
|
|
"hybrid".parse::<DataSourceType>().unwrap(),
|
|
DataSourceType::Hybrid
|
|
);
|
|
assert_eq!(
|
|
"parquet".parse::<DataSourceType>().unwrap(),
|
|
DataSourceType::Parquet
|
|
);
|
|
|
|
assert!("invalid".parse::<DataSourceType>().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_range_defaults() {
|
|
let config = TimeRangeConfig::default();
|
|
assert_eq!(config.duration_days, Some(30));
|
|
assert_eq!(config.train_split, 0.8);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_validation() {
|
|
let mut config = TrainingDataSourceConfig {
|
|
source_type: DataSourceType::Historical,
|
|
database: None, // Missing required database
|
|
s3: None,
|
|
time_range: TimeRangeConfig::default(),
|
|
symbols: vec![],
|
|
features: FeatureExtractionConfig::default(),
|
|
validation: DataValidationConfig::default(),
|
|
cache: CacheConfig::default(),
|
|
};
|
|
|
|
// Should fail - missing database
|
|
assert!(config.validate().is_err());
|
|
|
|
// Add database config
|
|
config.database = Some(DatabaseConfig {
|
|
connection_url: "postgresql://localhost/test".to_string(),
|
|
max_connections: 10,
|
|
query_timeout_secs: 300,
|
|
tables: DatabaseTables::default(),
|
|
});
|
|
|
|
// Should pass
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
}
|