Files
foxhunt/services/ml_training_service/src/data_loader.rs
jgrusewski 00ae84dd88 refactor: remove dead code and #[allow(dead_code)] annotations across workspace
Strip all 413 #[allow(dead_code)] annotations from 139 files and remove
the actual dead code they were suppressing: unused struct fields (and their
constructor sites), unused methods/functions, and entire dead structs.

Key removals:
- trading_engine compliance: ~50 dead structs/fields across audit, reporting, SOX modules
- trading_service: dead execution engine fields, broker routing, paper trading methods
- ml_training_service: dead TLS validation (~340 lines), GPU state, monitoring fields
- backtesting_service: dead model cache, TLS validation, TradeSignal fields
- risk: dead VaR engine fields, safety coordinator fields, position tracker fields
- adaptive-strategy: dead ensemble methods, regime detection, sizing functions

147 files changed, -4264 net lines. Workspace compiles with 0 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:12:20 +01:00

1255 lines
41 KiB
Rust

//! Historical Data Loader for ML Training
//!
//! This module provides the `HistoricalDataLoader` which loads training data from PostgreSQL
//! and converts it to `FinancialFeatures` for ML model training.
//!
//! ## Data Pipeline
//!
//! 1. **Load**: Query database tables (order_book_snapshots, trade_executions, market_events)
//! 2. **Filter**: Apply time range and symbol filters from configuration
//! 3. **Extract**: Compute technical indicators and microstructure features
//! 4. **Normalize**: Apply z-score, min-max, or robust scaling
//! 5. **Convert**: Transform to FinancialFeatures format
//!
//! ## Usage
//!
//! ```rust,no_run
//! use ml_training_service::data_loader::HistoricalDataLoader;
//! use ml_training_service::data_config::TrainingDataSourceConfig;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let config = TrainingDataSourceConfig::from_env()?;
//! let loader = HistoricalDataLoader::new(config).await?;
//! let (training_data, validation_data) = loader.load_training_data().await?;
//! # Ok(())
//! # }
//! ```
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use std::collections::{HashMap, VecDeque};
use tracing::{debug, info, warn};
use crate::data_config::{DatabaseConfig, TrainingDataSourceConfig};
use crate::schema_types::{MarketEvent, OrderBookSnapshot, TradeExecution};
use crate::technical_indicators::{IndicatorConfig, TechnicalIndicatorCalculator};
use common::Price;
use ml::training_pipeline::{FinancialFeatures, MicrostructureFeatures, RiskFeatures};
/// Risk metrics calculator for portfolio risk assessment
///
/// Maintains rolling price history and calculates financial risk metrics:
/// - VaR (Value at Risk) at 5% confidence level
///
/// - Expected Shortfall (CVaR) - average loss beyond VaR
/// - Maximum Drawdown - largest peak-to-trough decline
///
/// - Sharpe Ratio - risk-adjusted return metric
///
/// Uses log returns for better statistical properties and annualizes
/// metrics assuming 252 trading days per year.
struct RiskMetricsCalculator {
/// Rolling window of historical prices
price_history: VecDeque<f64>,
/// Maximum window size for calculations
window_size: usize,
/// Risk-free rate for Sharpe ratio (annualized)
risk_free_rate: f64,
}
impl RiskMetricsCalculator {
/// Create new risk metrics calculator
///
/// # Arguments
/// * `window_size` - Number of historical prices to maintain (default: 100)
///
/// * `risk_free_rate` - Annual risk-free rate (default: 0.0)
fn new(window_size: usize, risk_free_rate: f64) -> Self {
Self {
price_history: VecDeque::with_capacity(window_size),
window_size,
risk_free_rate,
}
}
/// Update with new price observation
fn update(&mut self, price: f64) {
if !price.is_finite() || price <= 0.0 {
return; // Skip invalid prices
}
self.price_history.push_back(price);
// Maintain window size
if self.price_history.len() > self.window_size {
self.price_history.pop_front();
}
}
/// Calculate log returns from price history
///
/// Returns: Vector of log returns ln(P_t / P_{t-1})
fn calculate_log_returns(&self) -> Vec<f64> {
if self.price_history.len() < 2 {
return Vec::new();
}
self.price_history
.iter()
.zip(self.price_history.iter().skip(1))
.map(|(prev, curr)| (curr / prev).ln())
.filter(|r| r.is_finite())
.collect()
}
/// Calculate Value at Risk (VaR) at given confidence level
///
/// # Arguments
/// * `confidence` - Confidence level (e.g., 0.05 for 5% VaR)
///
/// Returns: VaR as negative percentage loss
fn calculate_var(&self, confidence: f64) -> f64 {
let mut returns = self.calculate_log_returns();
if returns.is_empty() {
return -0.02; // Default: -2% if insufficient data
}
returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let index = (returns.len() as f64 * confidence).floor() as usize;
let index = index.min(returns.len().saturating_sub(1));
returns[index]
}
/// Calculate Expected Shortfall (CVaR) - average loss beyond VaR
///
/// # Arguments
/// * `var` - VaR threshold value
///
/// Returns: Expected shortfall as negative percentage
fn calculate_expected_shortfall(&self, var: f64) -> f64 {
let returns = self.calculate_log_returns();
if returns.is_empty() {
return -0.03; // Default: -3% if insufficient data
}
let tail_returns: Vec<f64> = returns.iter().filter(|&&r| r <= var).copied().collect();
if tail_returns.is_empty() {
return var; // If no tail, ES equals VaR
}
tail_returns.iter().sum::<f64>() / tail_returns.len() as f64
}
/// Calculate maximum drawdown from price history
///
/// Returns: Maximum peak-to-trough decline as negative percentage
fn calculate_max_drawdown(&self) -> f64 {
if self.price_history.len() < 2 {
return -0.05; // Default: -5% if insufficient data
}
let mut max_price = self.price_history.front().copied().unwrap_or(0.0);
let mut max_drawdown = 0.0;
for price in self.price_history.iter().skip(1) {
if *price > max_price {
max_price = *price;
} else {
let drawdown = (*price - max_price) / max_price;
if drawdown < max_drawdown {
max_drawdown = drawdown;
}
}
}
max_drawdown
}
/// Calculate Sharpe ratio - risk-adjusted return
///
/// Returns: Annualized Sharpe ratio (assumes 252 trading days)
fn calculate_sharpe_ratio(&self) -> f64 {
let returns = self.calculate_log_returns();
if returns.len() < 2 {
return 1.0; // Default: neutral Sharpe if insufficient data
}
let mean_return = returns.iter().sum::<f64>() / returns.len() as f64;
let variance = returns
.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f64>()
/ returns.len() as f64;
let std_dev = variance.sqrt();
if std_dev < 1e-10 {
return 0.0; // No volatility, undefined Sharpe
}
// Annualize: multiply mean by 252, std dev by sqrt(252)
let annualized_return = mean_return * 252.0;
let annualized_volatility = std_dev * (252.0_f64).sqrt();
(annualized_return - self.risk_free_rate) / annualized_volatility
}
/// Calculate all risk metrics at once
fn calculate_all_metrics(&self) -> RiskMetrics {
let var_5pct = self.calculate_var(0.05);
let expected_shortfall = self.calculate_expected_shortfall(var_5pct);
let max_drawdown = self.calculate_max_drawdown();
let sharpe_ratio = self.calculate_sharpe_ratio();
RiskMetrics {
var_5pct,
expected_shortfall,
max_drawdown,
sharpe_ratio,
}
}
}
/// Computed risk metrics
struct RiskMetrics {
var_5pct: f64,
expected_shortfall: f64,
max_drawdown: f64,
sharpe_ratio: f64,
}
/// Normalization method for feature scaling
#[derive(Debug, Clone)]
enum NormalizationMethod {
/// No normalization applied
None,
/// Z-score: (x - mean) / std_dev
ZScore,
/// Min-max: (x - min) / (max - min)
MinMax,
/// Robust: (x - median) / IQR
Robust,
}
impl NormalizationMethod {
fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"zscore" | "z-score" => Self::ZScore,
"minmax" | "min-max" => Self::MinMax,
"robust" => Self::Robust,
_ => Self::None,
}
}
}
/// Normalization parameters for a single feature
#[derive(Debug, Clone)]
pub struct NormalizationParams {
pub mean: f64,
pub std_dev: f64,
pub min: f64,
pub max: f64,
pub median: f64,
pub q1: f64, // 25th percentile
pub q3: f64, // 75th percentile
}
/// Complete normalization parameters for all features
///
/// Used to prevent data leakage by fitting on training set and applying to validation set
#[derive(Debug, Clone)]
pub struct FeatureNormalizationParams {
pub indicator_params: HashMap<String, NormalizationParams>,
pub spread_params: NormalizationParams,
pub imbalance_params: NormalizationParams,
pub intensity_params: NormalizationParams,
pub var_params: NormalizationParams,
pub es_params: NormalizationParams,
pub dd_params: NormalizationParams,
pub sharpe_params: NormalizationParams,
}
impl NormalizationParams {
/// Fit normalization parameters from data
fn fit(values: &[f64]) -> Self {
if values.is_empty() {
return Self::default();
}
let valid_values: Vec<f64> = values.iter().filter(|v| v.is_finite()).copied().collect();
if valid_values.is_empty() {
return Self::default();
}
// Calculate mean and std dev
let mean = valid_values.iter().sum::<f64>() / valid_values.len() as f64;
let variance = valid_values.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
/ valid_values.len() as f64;
let std_dev = variance.sqrt();
// Calculate min and max
let min = valid_values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max = valid_values
.iter()
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
// Calculate median and quartiles
let mut sorted = valid_values;
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = Self::percentile(&sorted, 0.5);
let q1 = Self::percentile(&sorted, 0.25);
let q3 = Self::percentile(&sorted, 0.75);
Self {
mean,
std_dev,
min,
max,
median,
q1,
q3,
}
}
/// Calculate percentile from sorted data
fn percentile(sorted_data: &[f64], p: f64) -> f64 {
if sorted_data.is_empty() {
return 0.0;
}
let index = (sorted_data.len() as f64 * p).floor() as usize;
let index = index.min(sorted_data.len() - 1);
sorted_data[index]
}
/// Apply normalization to a value
fn normalize(&self, value: f64, method: &NormalizationMethod) -> f64 {
if !value.is_finite() {
return 0.0;
}
match method {
NormalizationMethod::None => value,
NormalizationMethod::ZScore => {
if self.std_dev < 1e-10 {
0.0 // No variance, return 0
} else {
(value - self.mean) / self.std_dev
}
},
NormalizationMethod::MinMax => {
let range = self.max - self.min;
if range < 1e-10 {
0.0 // No range, return 0
} else {
(value - self.min) / range
}
},
NormalizationMethod::Robust => {
let iqr = self.q3 - self.q1;
if iqr < 1e-10 {
0.0 // No IQR, return 0
} else {
(value - self.median) / iqr
}
},
}
}
}
impl Default for NormalizationParams {
fn default() -> Self {
Self {
mean: 0.0,
std_dev: 1.0,
min: 0.0,
max: 1.0,
median: 0.0,
q1: 0.0,
q3: 1.0,
}
}
}
/// Historical data loader for ML training
///
/// Connects to Postgre`SQL` database and loads market data for ML model training.
///
/// Handles time-range filtering, symbol filtering, and feature extraction.
pub struct HistoricalDataLoader {
/// Database connection pool
pool: PgPool,
/// Data source configuration
config: TrainingDataSourceConfig,
/// Per-symbol technical indicator calculators
calculators: HashMap<String, TechnicalIndicatorCalculator>,
/// Per-symbol risk metrics calculators
risk_calculators: HashMap<String, RiskMetricsCalculator>,
}
impl HistoricalDataLoader {
/// Create a new historical data loader
///
/// Establishes database connection pool based on configuration.
///
/// # Arguments
///
/// * `config` - Training data source configuration with database settings
///
/// # Errors
///
/// Returns error if:
/// - Database configuration is missing
///
/// - Database connection fails
/// - Connection pool cannot be created
pub async fn new(config: TrainingDataSourceConfig) -> Result<Self> {
let database_config = config.database.as_ref().ok_or_else(|| {
anyhow::anyhow!("Database configuration required for historical data")
})?;
info!(
"Connecting to database: {}",
Self::sanitize_connection_url(&database_config.connection_url)
);
let pool = Self::create_connection_pool(database_config).await?;
info!("Database connection established successfully");
Ok(Self {
pool,
config,
calculators: HashMap::new(),
risk_calculators: HashMap::new(),
})
}
/// Create database connection pool with configuration
async fn create_connection_pool(database_config: &DatabaseConfig) -> Result<PgPool> {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(database_config.max_connections)
.acquire_timeout(std::time::Duration::from_secs(
database_config.query_timeout_secs,
))
.connect(&database_config.connection_url)
.await
.context("Failed to create database connection pool")?;
Ok(pool)
}
/// Sanitize connection URL for logging (hide password)
fn sanitize_connection_url(url: &str) -> String {
if let Some(at_pos) = url.find('@') {
if let Some(colon_pos) = url[..at_pos].rfind(':') {
let mut sanitized = url.to_string();
sanitized.replace_range(colon_pos + 1..at_pos, "****");
return sanitized;
}
}
url.to_string()
}
/// Load training data from database
///
/// Main entry point for data loading. Queries database tables, extracts features,
/// and splits into training/validation sets.
///
/// # Returns
///
/// Returns tuple of (training_data, validation_data) where each element is
/// `Vec<(FinancialFeatures, Vec<f64>)>` for model training.
///
/// # Errors
///
/// Returns error if:
/// - Database queries fail
///
/// - Insufficient data available
/// - Feature extraction fails
///
/// - Data validation fails
pub async fn load_training_data(
&mut self,
) -> Result<(
Vec<(FinancialFeatures, Vec<f64>)>,
Vec<(FinancialFeatures, Vec<f64>)>,
)> {
info!("Starting training data load from database");
// Step 1: Load raw data from database tables
let order_book_data = self.load_order_book_data().await?;
let trade_data = self.load_trade_data().await?;
let market_events = self.load_market_events().await?;
info!(
"Loaded raw data: {} order book snapshots, {} trades, {} events",
order_book_data.len(),
trade_data.len(),
market_events.len()
);
// Step 2: Validate data quality
self.validate_data_quality(&order_book_data, &trade_data)?;
// Step 3: Convert to FinancialFeatures with targets
let all_features =
self.convert_to_features_with_targets(order_book_data, trade_data, market_events)?;
info!("Converted to {} feature samples", all_features.len());
// Step 4: Split into training and validation sets
let (mut training_data, mut validation_data) = self.split_train_validation(all_features)?;
info!(
"Split data: {} training samples, {} validation samples",
training_data.len(),
validation_data.len()
);
// Step 5: Apply normalization to features
// Fit on training data, apply to both training and validation
// This prevents data leakage by using only training set statistics
if !training_data.is_empty() {
// Fit normalization parameters on training data
let normalization_params = self.fit_normalization(&training_data);
// Apply fitted parameters to training data
self.transform_with_params(&mut training_data, &normalization_params);
// Apply same parameters to validation data (prevents data leakage)
if !validation_data.is_empty() {
self.transform_with_params(&mut validation_data, &normalization_params);
}
}
Ok((training_data, validation_data))
}
/// Load order book snapshots from database
async fn load_order_book_data(&self) -> Result<Vec<OrderBookSnapshot>> {
let time_range = &self.config.time_range;
let symbols = &self.config.symbols;
let db = self.config.database.as_ref().ok_or_else(|| {
anyhow::anyhow!("Database configuration required for historical data loading")
})?;
let tables = &db.tables;
let query = if symbols.is_empty() {
// Load all symbols
format!(
r#"
SELECT * FROM {}
WHERE timestamp >= $1 AND timestamp <= $2
AND data_quality >= 80
ORDER BY timestamp ASC
LIMIT 100000
"#,
tables.order_books
)
} else {
// Load specific symbols
format!(
r#"
SELECT * FROM {}
WHERE timestamp >= $1 AND timestamp <= $2
AND symbol = ANY($3)
AND data_quality >= 80
ORDER BY timestamp ASC
LIMIT 100000
"#,
tables.order_books
)
};
let start = time_range
.start
.unwrap_or_else(|| Utc::now() - chrono::Duration::days(30));
let end = time_range.end.unwrap_or_else(Utc::now);
debug!("Querying order books from {} to {}", start, end);
let snapshots = if symbols.is_empty() {
sqlx::query_as::<_, OrderBookSnapshot>(&query)
.bind(start)
.bind(end)
.fetch_all(&self.pool)
.await
.context("Failed to query order book snapshots")?
} else {
sqlx::query_as::<_, OrderBookSnapshot>(&query)
.bind(start)
.bind(end)
.bind(symbols)
.fetch_all(&self.pool)
.await
.context("Failed to query order book snapshots")?
};
Ok(snapshots)
}
/// Load trade executions from database
async fn load_trade_data(&self) -> Result<Vec<TradeExecution>> {
let time_range = &self.config.time_range;
let symbols = &self.config.symbols;
let db = self.config.database.as_ref().ok_or_else(|| {
anyhow::anyhow!("Database configuration required for historical data loading")
})?;
let tables = &db.tables;
let query = if symbols.is_empty() {
format!(
r#"
SELECT * FROM {}
WHERE timestamp >= $1 AND timestamp <= $2
AND data_quality >= 80
ORDER BY timestamp ASC
LIMIT 100000
"#,
tables.trades
)
} else {
format!(
r#"
SELECT * FROM {}
WHERE timestamp >= $1 AND timestamp <= $2
AND symbol = ANY($3)
AND data_quality >= 80
ORDER BY timestamp ASC
LIMIT 100000
"#,
tables.trades
)
};
let start = time_range
.start
.unwrap_or_else(|| Utc::now() - chrono::Duration::days(30));
let end = time_range.end.unwrap_or_else(Utc::now);
debug!("Querying trades from {} to {}", start, end);
let trades = if symbols.is_empty() {
sqlx::query_as::<_, TradeExecution>(&query)
.bind(start)
.bind(end)
.fetch_all(&self.pool)
.await
.context("Failed to query trade executions")?
} else {
sqlx::query_as::<_, TradeExecution>(&query)
.bind(start)
.bind(end)
.bind(symbols)
.fetch_all(&self.pool)
.await
.context("Failed to query trade executions")?
};
Ok(trades)
}
/// Load market events from database
async fn load_market_events(&self) -> Result<Vec<MarketEvent>> {
let time_range = &self.config.time_range;
let symbols = &self.config.symbols;
let db = self.config.database.as_ref().ok_or_else(|| {
anyhow::anyhow!("Database configuration required for historical data loading")
})?;
let tables = &db.tables;
let query = if symbols.is_empty() {
format!(
r#"
SELECT * FROM {}
WHERE timestamp >= $1 AND timestamp <= $2
ORDER BY timestamp ASC
LIMIT 10000
"#,
tables.market_data
)
} else {
format!(
r#"
SELECT * FROM {}
WHERE timestamp >= $1 AND timestamp <= $2
AND (symbol = ANY($3) OR symbol IS NULL)
ORDER BY timestamp ASC
LIMIT 10000
"#,
tables.market_data
)
};
let start = time_range
.start
.unwrap_or_else(|| Utc::now() - chrono::Duration::days(30));
let end = time_range.end.unwrap_or_else(Utc::now);
debug!("Querying market events from {} to {}", start, end);
let events = if symbols.is_empty() {
sqlx::query_as::<_, MarketEvent>(&query)
.bind(start)
.bind(end)
.fetch_all(&self.pool)
.await
.context("Failed to query market events")?
} else {
sqlx::query_as::<_, MarketEvent>(&query)
.bind(start)
.bind(end)
.bind(symbols)
.fetch_all(&self.pool)
.await
.context("Failed to query market events")?
};
Ok(events)
}
/// Validate data quality and quantity
fn validate_data_quality(
&self,
order_book_data: &[OrderBookSnapshot],
_trade_data: &[TradeExecution],
) -> Result<()> {
let validation = &self.config.validation;
// Check minimum samples
let total_samples = order_book_data.len();
if total_samples < validation.min_samples {
anyhow::bail!(
"Insufficient data: {} samples (minimum {} required)",
total_samples,
validation.min_samples
);
}
// Check data quality distribution
let high_quality_count = order_book_data
.iter()
.filter(|s| s.is_high_quality())
.count();
let quality_ratio = high_quality_count as f64 / total_samples as f64;
if quality_ratio < (1.0 - validation.max_missing_ratio) {
warn!(
"Data quality concern: only {:.1}% high quality samples",
quality_ratio * 100.0
);
}
debug!(
"Data validation passed: {} samples, {:.1}% high quality",
total_samples,
quality_ratio * 100.0
);
Ok(())
}
/// Convert database rows to FinancialFeatures with targets
fn convert_to_features_with_targets(
&mut self,
order_book_data: Vec<OrderBookSnapshot>,
trade_data: Vec<TradeExecution>,
_market_events: Vec<MarketEvent>,
) -> Result<Vec<(FinancialFeatures, Vec<f64>)>> {
let mut result = Vec::new();
// Group trades by timestamp window for aggregation
let trade_map = self.group_trades_by_time(&trade_data);
// Convert each order book snapshot to features
for (i, snapshot) in order_book_data.iter().enumerate() {
// Extract features from order book
let features = self.snapshot_to_features(snapshot, &trade_map)?;
// Compute target (next price change) if we have future data
let target = if i + 1 < order_book_data.len() {
let next_snapshot = &order_book_data[i + 1];
vec![self.compute_price_change_target(snapshot, next_snapshot)]
} else {
vec![0.0] // Last sample has no target
};
result.push((features, target));
}
Ok(result)
}
/// Group trades by time windows for aggregation
fn group_trades_by_time<'a>(
&self,
trades: &'a [TradeExecution],
) -> HashMap<DateTime<Utc>, Vec<&'a TradeExecution>> {
let mut trade_map: HashMap<DateTime<Utc>, Vec<&TradeExecution>> = HashMap::new();
// Group trades into 1-second buckets
for trade in trades {
// Round timestamp to nearest second by truncating to second precision
let seconds = trade.timestamp.timestamp();
let bucket = DateTime::from_timestamp(seconds, 0).unwrap_or(trade.timestamp);
trade_map.entry(bucket).or_default().push(trade);
}
trade_map
}
/// Convert order book snapshot to FinancialFeatures
fn snapshot_to_features(
&mut self,
snapshot: &OrderBookSnapshot,
trade_map: &HashMap<DateTime<Utc>, Vec<&TradeExecution>>,
) -> Result<FinancialFeatures> {
// Price features
let raw_mid = snapshot.mid_price_f64();
let mid_price = Price::from_f64(raw_mid)
.or_else(|_| Price::new(raw_mid))
.map_err(|e| anyhow::anyhow!("Failed to construct mid_price: {}", e))?;
let prices = vec![mid_price];
// Volume features
let total_volume = (snapshot.bid_volume_f64() + snapshot.ask_volume_f64()) as i64;
let volumes = vec![total_volume];
// Technical indicators - Phase 3 implementation with stateful calculations
let mut technical_indicators = HashMap::new();
technical_indicators.insert("spread_bps".to_string(), snapshot.spread_bps as f64);
technical_indicators.insert("imbalance".to_string(), snapshot.imbalance);
// Get or create calculator for this symbol
let calculator = self
.calculators
.entry(snapshot.symbol.clone())
.or_insert_with(|| {
TechnicalIndicatorCalculator::new(
snapshot.symbol.clone(),
IndicatorConfig::default(),
)
});
// Update calculator with new price data
let price = snapshot.mid_price_f64();
let volume = snapshot.bid_volume_f64() + snapshot.ask_volume_f64();
calculator.update(price, volume, None, None);
// Extract calculated indicators
let calc_indicators = calculator.current_indicators();
// Merge calculator indicators into technical_indicators map
for (key, value) in calc_indicators {
technical_indicators.insert(key, value);
}
// Microstructure features
let vwap = self.calculate_vwap(snapshot, trade_map);
let trade_intensity = self.calculate_trade_intensity(snapshot.timestamp, trade_map);
let microstructure = MicrostructureFeatures {
spread_bps: snapshot.spread_bps,
imbalance: snapshot.imbalance,
trade_intensity,
vwap: Price::from_f64(vwap).unwrap_or(mid_price),
};
// Risk metrics calculated from rolling price history
let risk_calc = self
.risk_calculators
.entry(snapshot.symbol.clone())
.or_insert_with(|| RiskMetricsCalculator::new(100, 0.0));
// Update risk calculator with current price
risk_calc.update(price);
// Calculate all risk metrics
let risk_metrics_calc = risk_calc.calculate_all_metrics();
let risk_metrics = RiskFeatures {
var_5pct: risk_metrics_calc.var_5pct,
expected_shortfall: risk_metrics_calc.expected_shortfall,
max_drawdown: risk_metrics_calc.max_drawdown,
sharpe_ratio: risk_metrics_calc.sharpe_ratio,
};
Ok(FinancialFeatures {
prices,
volumes,
technical_indicators,
microstructure,
risk_metrics,
timestamp: snapshot.timestamp,
})
}
/// Calculate volume-weighted average price (VWAP)
fn calculate_vwap(
&self,
snapshot: &OrderBookSnapshot,
trade_map: &HashMap<DateTime<Utc>, Vec<&TradeExecution>>,
) -> f64 {
let seconds = snapshot.timestamp.timestamp();
let bucket = DateTime::from_timestamp(seconds, 0).unwrap_or(snapshot.timestamp);
if let Some(trades) = trade_map.get(&bucket) {
let mut total_value = 0.0;
let mut total_volume = 0.0;
for trade in trades {
if trade.symbol == snapshot.symbol {
total_value += trade.price_f64() * trade.quantity_f64();
total_volume += trade.quantity_f64();
}
}
if total_volume > 0.0 {
total_value / total_volume
} else {
snapshot.mid_price_f64()
}
} else {
snapshot.mid_price_f64()
}
}
/// Calculate trade intensity (trades per second)
fn calculate_trade_intensity(
&self,
timestamp: DateTime<Utc>,
trade_map: &HashMap<DateTime<Utc>, Vec<&TradeExecution>>,
) -> f64 {
let seconds = timestamp.timestamp();
let bucket = DateTime::from_timestamp(seconds, 0).unwrap_or(timestamp);
trade_map
.get(&bucket)
.map(|trades| trades.len() as f64)
.unwrap_or(0.0)
}
/// Compute price change target for prediction
fn compute_price_change_target(
&self,
current: &OrderBookSnapshot,
next: &OrderBookSnapshot,
) -> f64 {
let current_price = current.mid_price_f64();
let next_price = next.mid_price_f64();
if current_price > 0.0 {
(next_price - current_price) / current_price
} else {
0.0
}
}
/// Split data into training and validation sets
fn split_train_validation(
&self,
mut all_data: Vec<(FinancialFeatures, Vec<f64>)>,
) -> Result<(
Vec<(FinancialFeatures, Vec<f64>)>,
Vec<(FinancialFeatures, Vec<f64>)>,
)> {
let train_split = self.config.time_range.train_split;
let split_index = (all_data.len() as f64 * train_split) as usize;
if split_index >= all_data.len() {
anyhow::bail!(
"Invalid train split: {:.2} results in {} training samples from {} total",
train_split,
split_index,
all_data.len()
);
}
let validation_data = all_data.split_off(split_index);
let training_data = all_data;
Ok((training_data, validation_data))
}
/// Fit normalization parameters on training data
///
/// Computes statistics (mean, std, min, max, etc.) from training data only.
///
/// These parameters are then applied to both training and validation data
/// to prevent data leakage.
///
/// # Arguments
/// * `features_list` - Training data to fit parameters on
///
/// # Returns
/// * `FeatureNormalizationParams` - Fitted parameters for all features
pub fn fit_normalization(
&self,
features_list: &[(FinancialFeatures, Vec<f64>)],
) -> FeatureNormalizationParams {
if features_list.is_empty() {
return FeatureNormalizationParams {
indicator_params: HashMap::new(),
spread_params: NormalizationParams::default(),
imbalance_params: NormalizationParams::default(),
intensity_params: NormalizationParams::default(),
var_params: NormalizationParams::default(),
es_params: NormalizationParams::default(),
dd_params: NormalizationParams::default(),
sharpe_params: NormalizationParams::default(),
};
}
info!(
"Fitting normalization parameters on {} training samples",
features_list.len()
);
// Collect all technical indicator keys
let mut all_indicator_keys: Vec<String> = features_list
.first()
.map(|(f, _)| f.technical_indicators.keys().cloned().collect())
.unwrap_or_default();
all_indicator_keys.sort();
// Fit normalization parameters for each technical indicator
let mut indicator_params: HashMap<String, NormalizationParams> = HashMap::new();
for key in &all_indicator_keys {
let values: Vec<f64> = features_list
.iter()
.filter_map(|(f, _)| f.technical_indicators.get(key).copied())
.collect();
let params = NormalizationParams::fit(&values);
indicator_params.insert(key.clone(), params);
}
// Fit parameters for microstructure features
let spread_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.microstructure.spread_bps as f64)
.collect();
let spread_params = NormalizationParams::fit(&spread_values);
let imbalance_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.microstructure.imbalance)
.collect();
let imbalance_params = NormalizationParams::fit(&imbalance_values);
let intensity_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.microstructure.trade_intensity)
.collect();
let intensity_params = NormalizationParams::fit(&intensity_values);
// Fit parameters for risk metrics
let var_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.risk_metrics.var_5pct)
.collect();
let var_params = NormalizationParams::fit(&var_values);
let es_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.risk_metrics.expected_shortfall)
.collect();
let es_params = NormalizationParams::fit(&es_values);
let dd_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.risk_metrics.max_drawdown)
.collect();
let dd_params = NormalizationParams::fit(&dd_values);
let sharpe_values: Vec<f64> = features_list
.iter()
.map(|(f, _)| f.risk_metrics.sharpe_ratio)
.collect();
let sharpe_params = NormalizationParams::fit(&sharpe_values);
info!(
"Fitted normalization parameters for {} technical indicators",
all_indicator_keys.len()
);
FeatureNormalizationParams {
indicator_params,
spread_params,
imbalance_params,
intensity_params,
var_params,
es_params,
dd_params,
sharpe_params,
}
}
/// Apply pre-fitted normalization parameters to features
///
/// Uses normalization parameters fitted on training data to transform
/// features. This prevents data leakage when normalizing validation data.
///
/// # Arguments
/// * `features_list` - Mutable reference to features to normalize
///
/// * `params` - Pre-fitted normalization parameters (from fit_normalization)
pub fn transform_with_params(
&self,
features_list: &mut [(FinancialFeatures, Vec<f64>)],
params: &FeatureNormalizationParams,
) {
let method = NormalizationMethod::from_str(&self.config.features.normalization);
if matches!(method, NormalizationMethod::None) {
debug!("Normalization disabled, skipping");
return;
}
info!(
"Applying {:?} normalization to {} samples",
method,
features_list.len()
);
if features_list.is_empty() {
return;
}
// Apply normalization to all features using pre-fitted parameters
for (features, _) in features_list.iter_mut() {
// Normalize technical indicators
for (key, value) in features.technical_indicators.iter_mut() {
if let Some(indicator_params) = params.indicator_params.get(key) {
*value = indicator_params.normalize(*value, &method);
}
}
// Normalize microstructure features
features.microstructure.imbalance = params
.imbalance_params
.normalize(features.microstructure.imbalance, &method);
features.microstructure.trade_intensity = params
.intensity_params
.normalize(features.microstructure.trade_intensity, &method);
// Note: spread_bps is u16, so we normalize separately if needed
let normalized_spread = params
.spread_params
.normalize(features.microstructure.spread_bps as f64, &method);
// Store in technical_indicators for reference
features
.technical_indicators
.insert("spread_bps_normalized".to_string(), normalized_spread);
// Normalize risk metrics
features.risk_metrics.var_5pct = params
.var_params
.normalize(features.risk_metrics.var_5pct, &method);
features.risk_metrics.expected_shortfall = params
.es_params
.normalize(features.risk_metrics.expected_shortfall, &method);
features.risk_metrics.max_drawdown = params
.dd_params
.normalize(features.risk_metrics.max_drawdown, &method);
features.risk_metrics.sharpe_ratio = params
.sharpe_params
.normalize(features.risk_metrics.sharpe_ratio, &method);
}
info!("Normalization complete");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[tokio::test]
async fn test_price_change_calculation() {
let loader = create_test_loader();
let current = create_test_snapshot(100.0);
let next = create_test_snapshot(101.0);
let change = loader.compute_price_change_target(&current, &next);
assert!((change - 0.01).abs() < 1e-6); // 1% increase
}
#[tokio::test]
async fn test_vwap_calculation() {
let loader = create_test_loader();
let snapshot = create_test_snapshot(100.0);
let trade_map = HashMap::new();
let vwap = loader.calculate_vwap(&snapshot, &trade_map);
assert_eq!(vwap, 100.0); // No trades, should return mid price
}
fn create_test_loader() -> HistoricalDataLoader {
use crate::data_config::*;
let config = TrainingDataSourceConfig {
source_type: DataSourceType::Historical,
database: None,
s3: None,
time_range: TimeRangeConfig::default(),
symbols: vec![],
features: FeatureExtractionConfig::default(),
validation: DataValidationConfig::default(),
cache: CacheConfig::default(),
};
// Create a test pool that won't actually be used
// We use a minimal PgPoolOptions that will create an unconnected pool
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect_lazy("postgres://test:test@localhost:5432/test_db")
.expect("Failed to create test pool");
HistoricalDataLoader {
pool,
config,
calculators: HashMap::new(),
risk_calculators: HashMap::new(),
}
}
fn create_test_snapshot(mid_price: f64) -> OrderBookSnapshot {
OrderBookSnapshot {
id: 1,
timestamp: Utc::now(),
symbol: "TEST".to_string(),
best_bid: rust_decimal::Decimal::from_f64_retain(mid_price - 0.01).unwrap(),
best_ask: rust_decimal::Decimal::from_f64_retain(mid_price + 0.01).unwrap(),
bid_volume: rust_decimal::Decimal::from(1000),
ask_volume: rust_decimal::Decimal::from(1000),
spread_bps: 2,
mid_price: rust_decimal::Decimal::from_f64_retain(mid_price).unwrap(),
imbalance: 0.0,
bid_levels: None,
ask_levels: None,
exchange: Some("TEST".to_string()),
data_quality: Some(100),
created_at: Utc::now(),
}
}
}