Files
foxhunt/ml/src/training.rs
jgrusewski e5fd216b04 fix(trading_service, ml): replace silently-mock allocation data with logged defaults
- allocation.rs: get_asset_volatilities now uses flat 0.20 (20% annual vol)
  instead of index-scaled 0.15+i*0.05; get_covariance_matrix diagonal is
  0.04 (0.20^2) instead of 0.0225; get_ml_predictions uses 0.0 (neutral)
  instead of 0.05+i*0.02. All three emit tracing::warn so mock state is
  visible in production logs.
- training.rs: train_all emits tracing::warn that it is using a placeholder
  loop and directs callers to use model-specific trainers for production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:32:50 +01:00

553 lines
16 KiB
Rust

//! Simplified ML Training Implementation for Foxhunt HFT System
//!
//! This module provides basic ML training functionality optimized for compilation success.
//! Focus on working implementation over advanced features.
//!
//! ## New Unified Data Pipeline
//!
//! The training system now uses UnifiedDataLoader with dual data providers:
//! - DatabentoHistoricalProvider for market data
//! - BenzingaHistoricalProvider for news sentiment
//! - UnifiedFeatureExtractor for consistent feature extraction
// Sub-modules for specialized training components
pub mod orchestrator;
pub mod unified_data_loader;
pub mod unified_trainer; // NEW: Unified training trait for all models // NEW: Model-agnostic training orchestrator
// NO RE-EXPORTS - Use explicit imports: unified_data_loader::{...}
use std::collections::HashMap;
use std::time::Instant;
use async_trait::async_trait;
use ndarray::Array1;
use serde::{Deserialize, Serialize};
use tokio::time::Duration;
use tracing::info;
// Import CommonError for consistent error handling
use common::error::CommonError;
/// Activation function types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ActivationType {
ReLU,
Sigmoid,
Tanh,
LeakyReLU,
}
/// Network configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
pub input_dim: usize,
pub hidden_dims: Vec<usize>,
pub output_dim: usize,
pub dropout_rate: f64,
pub activation: ActivationType,
}
/// Training configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingConfig {
pub learning_rate: f64,
pub batch_size: usize,
pub epochs: usize,
pub validation_split: f64,
pub early_stopping_patience: Option<usize>,
pub random_seed: Option<u64>,
}
impl Default for TrainingConfig {
fn default() -> Self {
Self {
learning_rate: 0.001,
batch_size: 32,
epochs: 100,
validation_split: 0.2,
early_stopping_patience: Some(10),
random_seed: None,
}
}
}
/// Simple neural network implementation
#[derive(Debug, Clone)]
pub struct SimpleNeuralNetwork {
pub config: NetworkConfig,
pub weights: Vec<Array1<f64>>,
pub biases: Vec<Array1<f64>>,
pub is_trained: bool,
}
impl SimpleNeuralNetwork {
/// Create a new neural network with the specified configuration
///
/// # Errors
///
/// Returns `CommonError` if:
/// - Configuration is invalid
/// - Weight initialization fails
/// - Memory allocation fails
/// - Layer dimensions are incompatible
pub fn new(config: NetworkConfig) -> Result<Self, CommonError> {
let mut weights = Vec::new();
let mut biases = Vec::new();
let mut layer_dims = vec![config.input_dim];
layer_dims.extend(&config.hidden_dims);
layer_dims.push(config.output_dim);
for i in 0..layer_dims.len() - 1 {
let input_size = layer_dims[i];
let output_size = layer_dims[i + 1];
// Initialize weights with random values
let weight = Array1::from_vec(
(0..input_size * output_size)
.map(|_| fastrand::f64() * 2.0 - 1.0)
.collect(),
);
weights.push(weight);
// Initialize biases to zero
let bias = Array1::zeros(output_size);
biases.push(bias);
}
Ok(Self {
config,
weights,
biases,
is_trained: false,
})
}
pub fn forward(&self, input: &Array1<f64>) -> Result<Array1<f64>, CommonError> {
let mut current = input.clone();
for (i, (weight, bias)) in self.weights.iter().zip(&self.biases).enumerate() {
// Simple matrix multiplication (simplified)
let output_size = bias.len();
let mut output = Array1::zeros(output_size);
for j in 0..output_size {
let mut sum = bias[j];
for k in 0..current.len() {
sum += current[k] * weight[k * output_size + j];
}
output[j] = sum;
}
// Apply activation if not the last layer
if i < self.weights.len() - 1 {
current = self.apply_activation(&output)?;
} else {
current = output;
}
}
Ok(current)
}
pub fn apply_activation(&self, input: &Array1<f64>) -> Result<Array1<f64>, CommonError> {
let result = match self.config.activation {
ActivationType::ReLU => input.mapv(|x| x.max(0.0)),
ActivationType::Sigmoid => input.mapv(|x| 1.0 / (1.0 + (-x).exp())),
ActivationType::Tanh => input.mapv(|x| x.tanh()),
ActivationType::LeakyReLU => input.mapv(|x| if x > 0.0 { x } else { x * 0.01 }),
};
Ok(result)
}
pub async fn predict_fast(&self, input: &[f64]) -> Result<Vec<f64>, CommonError> {
if !self.is_trained {
return Err(CommonError::validation(
"Model must be trained before prediction".to_string(),
));
}
let input_array = Array1::from_vec(input.to_vec());
let output = self.forward(&input_array)?;
Ok(output.to_vec())
}
}
/// Training metrics
#[derive(Debug, Default, Clone)]
pub struct TrainingMetrics {
pub train_losses: Vec<f64>,
pub validation_losses: Vec<f64>,
pub train_accuracies: Vec<f64>,
pub validation_accuracies: Vec<f64>,
pub best_validation_accuracy: f64,
pub best_epoch: usize,
pub final_train_loss: f64,
pub final_validation_loss: f64,
pub early_stopped: bool,
pub training_time_seconds: f64,
start_time: Option<Instant>,
}
impl TrainingMetrics {
pub fn new() -> Self {
Self {
start_time: Some(Instant::now()),
..Default::default()
}
}
pub fn add_epoch_results(
&mut self,
train_loss: f64,
val_loss: f64,
train_acc: f64,
val_acc: f64,
epoch: usize,
) {
self.train_losses.push(train_loss);
self.validation_losses.push(val_loss);
self.train_accuracies.push(train_acc);
self.validation_accuracies.push(val_acc);
if val_acc > self.best_validation_accuracy {
self.best_validation_accuracy = val_acc;
self.best_epoch = epoch;
}
self.final_train_loss = train_loss;
self.final_validation_loss = val_loss;
}
pub fn complete_training(&mut self, early_stopped: bool) {
self.early_stopped = early_stopped;
if let Some(start) = self.start_time {
self.training_time_seconds = start.elapsed().as_secs_f64();
}
}
}
/// Device capabilities for performance scoring
#[derive(Debug, Clone)]
pub struct DeviceCapabilities {
pub performance_score: f64,
pub memory_gb: f64,
pub compute_units: u32,
}
impl DeviceCapabilities {
pub fn cpu_default() -> Self {
Self {
performance_score: 1.0,
memory_gb: 8.0,
compute_units: num_cpus::get() as u32,
}
}
}
/// Network interface trait
#[async_trait]
pub trait NetworkInterface {
async fn inference_hft(&self, input: &[f32]) -> Result<Vec<f32>, CommonError>;
}
#[cfg(test)]
/// Mock network for testing only - isolated from production
#[derive(Debug, Clone)]
pub struct MockNetwork {
config: NetworkConfig,
}
#[cfg(test)]
impl MockNetwork {
pub fn new(config: NetworkConfig) -> Self {
Self { config }
}
}
#[cfg(test)]
#[async_trait]
impl NetworkInterface for MockNetwork {
async fn inference_hft(&self, _input: &[f32]) -> Result<Vec<f32>, CommonError> {
// Mock inference - just return zeros of expected output size
Ok(vec![0.0; self.config.output_dim])
}
}
/// Training pipeline
#[derive(Debug)]
pub struct TrainingPipeline {
pub config: TrainingConfig,
models: HashMap<String, SimpleNeuralNetwork>,
device_caps: DeviceCapabilities,
statistics: HashMap<String, f64>,
}
impl TrainingPipeline {
pub fn new(config: TrainingConfig) -> Self {
let mut statistics = HashMap::new();
statistics.insert("total_models".to_string(), 0.0);
statistics.insert("trained_models".to_string(), 0.0);
Self {
config,
models: HashMap::new(),
device_caps: DeviceCapabilities::cpu_default(),
statistics,
}
}
pub fn device_capabilities(&self) -> &DeviceCapabilities {
&self.device_caps
}
pub fn register_model(
&mut self,
name: String,
model: SimpleNeuralNetwork,
) -> Result<(), CommonError> {
self.models.insert(name, model);
if let Some(total_models) = self.statistics.get_mut("total_models") {
*total_models += 1.0;
}
Ok(())
}
#[cfg(test)]
pub async fn create_network(&self, config: NetworkConfig) -> Result<MockNetwork, CommonError> {
let network = MockNetwork::new(config);
Ok(network)
}
pub async fn train_all_models(
&mut self,
_training_data: &[(Array1<f64>, Array1<f64>)],
) -> Result<HashMap<String, TrainingMetrics>, CommonError> {
let mut results = HashMap::new();
for (name, model) in &mut self.models {
info!("Training model: {}", name);
let mut metrics = TrainingMetrics::new();
// SIMPLIFIED: Basic training loop - full implementation requires actual model training
// TODO: Implement proper gradient descent, backpropagation, and loss calculation
tracing::warn!(
"train_all: using placeholder training loop. Use model-specific trainers for production."
);
for epoch in 0..self.config.epochs.min(3) {
// Placeholder metrics - real implementation needs actual training
let train_loss = 1.0 / (epoch as f64 + 1.0);
let val_loss = train_loss * 1.1;
let train_acc = 0.5 + 0.3 * epoch as f64 / self.config.epochs as f64;
let val_acc = train_acc * 0.9;
metrics.add_epoch_results(train_loss, val_loss, train_acc, val_acc, epoch);
tokio::time::sleep(Duration::from_millis(10)).await;
}
model.is_trained = true;
metrics.complete_training(false);
results.insert(name.clone(), metrics);
if let Some(trained_models) = self.statistics.get_mut("trained_models") {
*trained_models += 1.0;
}
}
Ok(results)
}
pub fn get_statistics(&self) -> &HashMap<String, f64> {
&self.statistics
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_training_config_default() {
let config = TrainingConfig::default();
assert_eq!(config.learning_rate, 0.001);
assert_eq!(config.batch_size, 32);
assert_eq!(config.epochs, 100);
assert_relative_eq!(config.validation_split, 0.2, epsilon = 1e-10);
}
#[test]
fn test_network_creation() -> Result<(), CommonError> {
let config = NetworkConfig {
input_dim: 5,
hidden_dims: vec![10, 8],
output_dim: 3,
activation: ActivationType::ReLU,
dropout_rate: 0.1,
};
let network = SimpleNeuralNetwork::new(config.clone())?;
assert_eq!(network.config.input_dim, 5);
assert_eq!(network.config.output_dim, 3);
assert!(!network.is_trained);
assert_eq!(network.weights.len(), 3); // 2 hidden + 1 output
assert_eq!(network.biases.len(), 3);
Ok(())
}
#[test]
fn test_forward_pass() -> Result<(), CommonError> {
let config = NetworkConfig {
input_dim: 3,
hidden_dims: vec![5],
output_dim: 2,
activation: ActivationType::ReLU,
dropout_rate: 0.0,
};
let network = SimpleNeuralNetwork::new(config)?;
let input = Array1::from(vec![1.0, 2.0, 3.0]);
let output = network.forward(&input)?;
assert_eq!(output.len(), 2);
Ok(())
}
#[test]
fn test_activation_functions() -> Result<(), CommonError> {
let input = Array1::from(vec![-2.0, -1.0, 0.0, 1.0, 2.0]);
// Test ReLU
let relu_config = NetworkConfig {
input_dim: 5,
hidden_dims: vec![],
output_dim: 5,
activation: ActivationType::ReLU,
dropout_rate: 0.0,
};
let relu_network = SimpleNeuralNetwork::new(relu_config)?;
let relu_output = relu_network.apply_activation(&input)?;
// ReLU should clip negative values to 0
assert!(relu_output[0] >= 0.0);
assert!(relu_output[1] >= 0.0);
// Test Sigmoid
let sigmoid_config = NetworkConfig {
input_dim: 5,
hidden_dims: vec![],
output_dim: 5,
activation: ActivationType::Sigmoid,
dropout_rate: 0.0,
};
let sigmoid_network = SimpleNeuralNetwork::new(sigmoid_config)?;
let sigmoid_output = sigmoid_network.apply_activation(&input)?;
// Sigmoid output should be between 0 and 1
for &val in &sigmoid_output {
assert!(val >= 0.0 && val <= 1.0);
}
Ok(())
}
#[tokio::test]
async fn test_training_pipeline() -> Result<(), CommonError> {
// Create a simple training dataset
let mut training_data = Vec::new();
for i in 0..100 {
let x = i as f64 * 0.1;
let input = Array1::from(vec![x, x * x]);
let output = Array1::from(vec![x * 2.0]); // Simple linear relationship
training_data.push((input, output));
}
let config = TrainingConfig {
epochs: 10,
learning_rate: 0.01,
batch_size: 10,
validation_split: 0.2,
early_stopping_patience: Some(5),
random_seed: Some(42),
};
let mut pipeline = TrainingPipeline::new(config);
// Create and register a simple model
let model_config = NetworkConfig {
input_dim: 2,
hidden_dims: vec![4],
output_dim: 1,
activation: ActivationType::ReLU,
dropout_rate: 0.0,
};
let model = SimpleNeuralNetwork::new(model_config)?;
pipeline.register_model("test_model".to_string(), model)?;
// Train the model
let results = pipeline.train_all_models(&training_data).await?;
assert_eq!(results.len(), 1);
assert!(results.contains_key("test_model"));
let stats = pipeline.get_statistics();
assert_eq!(stats.get("total_models").unwrap(), &1.0);
assert_eq!(stats.get("trained_models").unwrap(), &1.0);
Ok(())
}
#[tokio::test]
async fn test_fast_inference() -> Result<(), CommonError> {
let config = NetworkConfig {
input_dim: 4,
hidden_dims: vec![8],
output_dim: 2,
activation: ActivationType::ReLU,
dropout_rate: 0.0,
};
let mut network = SimpleNeuralNetwork::new(config)?;
// Test prediction before training (should fail)
let input = vec![1.0, 2.0, 3.0, 4.0];
let result = network.predict_fast(&input).await;
assert!(result.is_err());
// Mock training by setting is_trained to true
network.is_trained = true;
// Test prediction after "training"
let result = network.predict_fast(&input).await?;
assert_eq!(result.len(), 2);
Ok(())
}
#[test]
fn test_training_metrics() {
let mut metrics = TrainingMetrics::new();
// Add some epoch results
metrics.add_epoch_results(0.8, 0.9, 0.7, 0.6, 0);
metrics.add_epoch_results(0.6, 0.7, 0.8, 0.75, 1);
metrics.add_epoch_results(0.5, 0.6, 0.85, 0.8, 2);
assert_eq!(metrics.train_losses.len(), 3);
assert_eq!(metrics.best_validation_accuracy, 0.8);
assert_eq!(metrics.best_epoch, 2);
assert_relative_eq!(metrics.final_train_loss, 0.5, epsilon = 1e-10);
assert_relative_eq!(metrics.final_validation_loss, 0.6, epsilon = 1e-10);
metrics.complete_training(false);
assert!(!metrics.early_stopped);
assert!(metrics.training_time_seconds >= 0.0);
}
}