Files
foxhunt/services/ml_training_service/src/orchestrator.rs
jgrusewski e4870b17b9 fix: tune log levels across workspace — demote noisy warn to debug/trace
Reduce log noise for non-critical operational paths: connection retries,
expected fallbacks, graceful degradation, and optional feature absence.
Keeps warn/error for genuine failures requiring attention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:35:15 +01:00

1292 lines
47 KiB
Rust

//! Training Orchestrator
//!
//! This module coordinates training jobs using the existing ML training infrastructure
//! from the ml crate. It manages job queues, resource allocation, and progress tracking.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use chrono::{DateTime, Utc};
use tokio::sync::{broadcast, mpsc, Mutex, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
// Import from existing ML infrastructure
use ml::training_pipeline::{
FinancialFeatures, ProductionMLTrainingSystem, ProductionTrainingConfig, TrainingResult,
};
use crate::data_config::TrainingDataSourceConfig;
use crate::database::DatabaseManager;
use crate::storage::ModelStorageManager;
use config::MLConfig;
/// Training job status
#[derive(Debug, Clone, PartialEq)]
pub enum JobStatus {
Pending,
Running,
Completed,
Failed,
Stopped,
Paused,
}
/// Training job metadata
#[derive(Debug, Clone)]
pub struct TrainingJob {
pub id: Uuid,
pub model_type: String,
pub status: JobStatus,
pub config: ProductionTrainingConfig,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub description: String,
pub tags: HashMap<String, String>,
pub progress_percentage: f32,
pub current_epoch: u32,
pub total_epochs: u32,
pub metrics: HashMap<String, f64>,
pub error_message: Option<String>,
pub model_artifact_path: Option<String>,
}
impl TrainingJob {
pub fn new(
model_type: String,
config: ProductionTrainingConfig,
description: String,
tags: HashMap<String, String>,
) -> Self {
Self {
id: Uuid::new_v4(),
model_type,
status: JobStatus::Pending,
config,
created_at: Utc::now(),
started_at: None,
completed_at: None,
description,
tags,
progress_percentage: 0.0,
current_epoch: 0,
total_epochs: 0,
metrics: HashMap::new(),
error_message: None,
model_artifact_path: None,
}
}
}
/// Resource allocation for training jobs
#[derive(Debug, Clone)]
pub struct ResourceAllocation {
pub gpu_id: Option<u32>,
pub cpu_cores: u32,
pub memory_gb: f64,
pub worker_id: u32,
}
/// Training orchestrator that manages job lifecycle and resources
pub struct TrainingOrchestrator {
config: MLConfig,
// Job management
jobs: Arc<RwLock<HashMap<Uuid, TrainingJob>>>,
job_queue: Arc<Mutex<mpsc::Sender<Uuid>>>,
job_receiver: Arc<Mutex<mpsc::Receiver<Uuid>>>,
// Resource management
available_resources: Arc<Mutex<Vec<ResourceAllocation>>>,
resource_assignments: Arc<RwLock<HashMap<Uuid, ResourceAllocation>>>,
// Progress tracking
status_broadcasters: Arc<RwLock<HashMap<Uuid, broadcast::Sender<TrainingStatusUpdate>>>>,
// External dependencies
database: Arc<crate::database::DatabaseManager>,
storage: Arc<ModelStorageManager>,
// Worker pool
worker_handles: Vec<tokio::task::JoinHandle<()>>,
// Graceful shutdown
cancellation_token: CancellationToken,
}
/// Status update message for broadcasting
#[derive(Debug, Clone)]
pub struct TrainingStatusUpdate {
pub job_id: Uuid,
pub status: JobStatus,
pub progress_percentage: f32,
pub current_epoch: u32,
pub total_epochs: u32,
pub metrics: HashMap<String, f64>,
pub message: String,
pub timestamp: DateTime<Utc>,
pub financial_metrics: Option<ml::training_pipeline::FinancialPerformanceMetrics>,
pub resource_usage: ResourceUsage,
}
#[derive(Debug, Clone)]
pub struct ResourceUsage {
pub cpu_usage_percent: f32,
pub memory_usage_gb: f32,
pub gpu_usage_percent: Option<f32>,
pub gpu_memory_usage_gb: Option<f32>,
pub active_workers: u32,
}
impl TrainingOrchestrator {
/// Create a new training orchestrator
pub async fn new(
config: MLConfig,
database: Arc<crate::database::DatabaseManager>,
storage: Arc<ModelStorageManager>,
) -> Result<Self> {
let (job_sender, job_receiver) = mpsc::channel(1000); // Default queue capacity
// Initialize available resources based on system capabilities
let available_resources = Self::detect_system_resources(&config).await?;
let orchestrator = Self {
config: config.clone(),
jobs: Arc::new(RwLock::new(HashMap::new())),
job_queue: Arc::new(Mutex::new(job_sender)),
job_receiver: Arc::new(Mutex::new(job_receiver)),
available_resources: Arc::new(Mutex::new(available_resources)),
resource_assignments: Arc::new(RwLock::new(HashMap::new())),
status_broadcasters: Arc::new(RwLock::new(HashMap::new())),
database,
storage,
worker_handles: Vec::new(),
cancellation_token: CancellationToken::new(),
};
info!("Training orchestrator initialized with default configuration");
Ok(orchestrator)
}
/// Start the orchestrator worker pool
pub async fn start(&mut self) -> Result<()> {
info!("Starting training orchestrator with default worker configuration");
// Start worker tasks with default thread count
let worker_count = 4; // Default worker threads
for worker_id in 0..worker_count {
let worker_handle = self.spawn_worker(worker_id).await?;
self.worker_handles.push(worker_handle);
}
// Start resource monitor
let resource_monitor_handle = self.spawn_resource_monitor().await?;
self.worker_handles.push(resource_monitor_handle);
// Start snapshot broadcaster for fallback status updates
let snapshot_broadcaster_handle = self.spawn_snapshot_broadcaster().await?;
self.worker_handles.push(snapshot_broadcaster_handle);
info!("Training orchestrator started successfully");
Ok(())
}
/// Gracefully shutdown the orchestrator
pub async fn shutdown(&mut self) -> Result<()> {
info!("Initiating graceful shutdown of training orchestrator");
// Signal all workers to stop
self.cancellation_token.cancel();
// Wait for all workers to finish with timeout
let shutdown_timeout = Duration::from_secs(30);
let mut remaining_handles = Vec::new();
for handle in self.worker_handles.drain(..) {
remaining_handles.push(handle);
}
// Wait for workers with timeout
if let Err(_) = tokio::time::timeout(shutdown_timeout, async {
for handle in remaining_handles {
let _ = handle.await;
}
})
.await
{
warn!("Some workers did not shutdown gracefully within timeout");
}
// Clean up any remaining broadcasters for proper resource cleanup
{
let mut broadcasters = self.status_broadcasters.write().await;
broadcasters.clear();
}
info!("Training orchestrator shutdown completed");
Ok(())
}
/// Clean up disconnected broadcasters to prevent memory leaks
pub async fn cleanup_disconnected_broadcasters(&self) {
let mut broadcasters = self.status_broadcasters.write().await;
let mut to_remove = Vec::new();
for (job_id, broadcaster) in broadcasters.iter() {
if broadcaster.receiver_count() == 0 {
to_remove.push(*job_id);
}
}
for job_id in to_remove {
broadcasters.remove(&job_id);
debug!("Cleaned up broadcaster for job {}", job_id);
}
}
/// Submit a new training job
pub async fn submit_job(
&self,
model_type: String,
config: ProductionTrainingConfig,
description: String,
tags: HashMap<String, String>,
) -> Result<Uuid> {
let job = TrainingJob::new(model_type, config, description, tags);
let job_id = job.id;
// Store job in database
let job_record = crate::database::TrainingJobRecord::from_training_job(&job);
if let Err(e) = self.database.insert_training_job(&job_record).await {
error!("Failed to store job {} in database: {}", job_id, e);
// Continue - in-memory storage still works
} else {
info!("Job {} successfully stored in database", job_id);
}
// Add to in-memory jobs
{
let mut jobs = self.jobs.write().await;
jobs.insert(job_id, job);
}
// Create status broadcaster for this job
let (tx, _) = broadcast::channel(100); // Default broadcast capacity
{
let mut broadcasters = self.status_broadcasters.write().await;
broadcasters.insert(job_id, tx);
}
// Queue the job for execution
{
let queue = self.job_queue.lock().await;
match queue.try_send(job_id) {
Ok(()) => {},
Err(mpsc::error::TrySendError::Full(_)) => {
return Err(anyhow::anyhow!(
"Job queue is full. System is under high load. Please try again later."
));
},
Err(mpsc::error::TrySendError::Closed(_)) => {
return Err(anyhow::anyhow!(
"Job queue is closed. System is shutting down."
));
},
}
}
info!(
"Submitted training job {} for model type {}",
job_id,
self.get_job_model_type(job_id)
.await
.unwrap_or("unknown".to_string())
);
Ok(job_id)
}
/// Stop a running training job
pub async fn stop_job(&self, job_id: Uuid, reason: String) -> Result<bool> {
let mut job_updated = false;
// Update job status
{
let mut jobs = self.jobs.write().await;
if let Some(job) = jobs.get_mut(&job_id) {
if matches!(job.status, JobStatus::Running | JobStatus::Pending) {
job.status = JobStatus::Stopped;
job.completed_at = Some(Utc::now());
job.error_message = Some(format!("Stopped: {}", reason));
job_updated = true;
}
}
}
if job_updated {
// Update database
let jobs_read = self.jobs.read().await;
if let Some(job) = jobs_read.get(&job_id) {
let job_record = crate::database::TrainingJobRecord::from_training_job(job);
drop(jobs_read); // Release lock before async call
if let Err(e) = self.database.update_training_job(&job_record).await {
error!("Failed to update job {} in database: {}", job_id, e);
} else {
info!("Job {} successfully updated in database", job_id);
}
}
// Broadcast status update
self.broadcast_status_update(job_id, "Job stopped by user request".to_string())
.await;
info!("Stopped training job {}: {}", job_id, reason);
}
Ok(job_updated)
}
/// Get job status
pub async fn get_job(&self, job_id: Uuid) -> Result<TrainingJob> {
let jobs = self.jobs.read().await;
jobs.get(&job_id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Job {} not found", job_id))
}
/// List all jobs with optional filtering
pub async fn list_jobs(
&self,
status_filter: Option<JobStatus>,
model_type_filter: Option<String>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Vec<TrainingJob>> {
let jobs = self.jobs.read().await;
let mut filtered_jobs: Vec<_> = jobs
.values()
.filter(|job| {
if let Some(ref status) = status_filter {
if job.status != *status {
return false;
}
}
if let Some(ref model_type) = model_type_filter {
if job.model_type != *model_type {
return false;
}
}
true
})
.cloned()
.collect();
// Sort by creation time (newest first)
filtered_jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
// Apply pagination
if let Some(offset) = offset {
if offset < filtered_jobs.len() {
filtered_jobs = filtered_jobs.into_iter().skip(offset).collect();
} else {
filtered_jobs.clear();
}
}
if let Some(limit) = limit {
filtered_jobs.truncate(limit);
}
Ok(filtered_jobs)
}
/// Subscribe to status updates for a job
pub async fn subscribe_to_job_status(
&self,
job_id: Uuid,
) -> Result<broadcast::Receiver<TrainingStatusUpdate>> {
let broadcasters = self.status_broadcasters.read().await;
if let Some(broadcaster) = broadcasters.get(&job_id) {
Ok(broadcaster.subscribe())
} else {
Err(anyhow::anyhow!("Job {} not found", job_id))
}
}
/// Detect available system resources
async fn detect_system_resources(_config: &MLConfig) -> Result<Vec<ResourceAllocation>> {
let mut resources = Vec::new();
// Default configuration for resource allocation
let worker_threads = 4u32;
let default_device = "cpu"; // Default to CPU for safety
let max_memory_gb = 8.0; // Default memory allocation
// For now, create one resource allocation per worker thread
// In a real implementation, this would detect actual GPU devices
for worker_id in 0..worker_threads {
let allocation = ResourceAllocation {
gpu_id: if default_device == "cuda" {
Some(0)
} else {
None
},
cpu_cores: num_cpus::get() as u32 / worker_threads,
memory_gb: max_memory_gb / worker_threads as f64,
worker_id,
};
resources.push(allocation);
}
info!("Detected {} resource allocations", resources.len());
Ok(resources)
}
/// Spawn a worker task
async fn spawn_worker(&self, worker_id: usize) -> Result<tokio::task::JoinHandle<()>> {
let jobs = Arc::clone(&self.jobs);
let job_receiver = Arc::clone(&self.job_receiver);
let available_resources = Arc::clone(&self.available_resources);
let resource_assignments = Arc::clone(&self.resource_assignments);
let status_broadcasters = Arc::clone(&self.status_broadcasters);
let database = Arc::clone(&self.database);
let storage = Arc::clone(&self.storage);
let config = self.config.clone();
let cancellation_token = self.cancellation_token.child_token();
let handle = tokio::spawn(async move {
info!("Training worker {} started", worker_id);
loop {
// Wait for a job to process
let job_id = {
let mut receiver = job_receiver.lock().await;
tokio::select! {
job_result = receiver.recv() => {
match job_result {
Some(id) => id,
None => {
warn!("Worker {} job receiver closed", worker_id);
break;
}
}
}
_ = cancellation_token.cancelled() => {
info!("Worker {} received cancellation signal", worker_id);
break;
}
}
};
// Process the job
if let Err(e) = Self::process_job(
job_id,
worker_id,
&jobs,
&available_resources,
&resource_assignments,
&status_broadcasters,
&database,
&storage,
&config,
)
.await
{
error!(
"Worker {} failed to process job {}: {}",
worker_id, job_id, e
);
}
}
info!("Training worker {} stopped", worker_id);
});
Ok(handle)
}
/// Process a single training job
async fn process_job(
job_id: Uuid,
worker_id: usize,
jobs: &Arc<RwLock<HashMap<Uuid, TrainingJob>>>,
available_resources: &Arc<Mutex<Vec<ResourceAllocation>>>,
resource_assignments: &Arc<RwLock<HashMap<Uuid, ResourceAllocation>>>,
status_broadcasters: &Arc<RwLock<HashMap<Uuid, broadcast::Sender<TrainingStatusUpdate>>>>,
database: &Arc<DatabaseManager>,
storage: &Arc<ModelStorageManager>,
_config: &MLConfig,
) -> Result<()> {
info!("Worker {} processing job {}", worker_id, job_id);
// Get job details
let (job_config, model_type) = {
let jobs_read = jobs.read().await;
if let Some(job) = jobs_read.get(&job_id) {
(job.config.clone(), job.model_type.clone())
} else {
return Err(anyhow::anyhow!("Job {} not found", job_id));
}
};
// Allocate resources
let resource_allocation = {
let mut resources = available_resources.lock().await;
if let Some(resource) = resources.pop() {
resource
} else {
error!("No available resources for job {}", job_id);
return Self::mark_job_failed(
job_id,
"No available resources".to_string(),
jobs,
database,
)
.await;
}
};
// Assign resource to job
{
let mut assignments = resource_assignments.write().await;
assignments.insert(job_id, resource_allocation.clone());
}
// Update job status to running
{
let mut jobs_write = jobs.write().await;
if let Some(job) = jobs_write.get_mut(&job_id) {
job.status = JobStatus::Running;
job.started_at = Some(Utc::now());
}
}
// Broadcast status update
Self::send_status_update(
job_id,
JobStatus::Running,
0.0,
0,
job_config.training_params.max_epochs as u32,
HashMap::new(),
"Training started".to_string(),
None,
&resource_allocation,
status_broadcasters,
)
.await;
// Execute training using existing ML infrastructure
let training_result = Self::execute_training(
job_id,
job_config,
model_type,
&resource_allocation,
status_broadcasters,
)
.await;
// Handle training result
match training_result {
Ok(result) => {
Self::handle_training_success(job_id, result, jobs, database, storage).await?;
},
Err(e) => {
Self::mark_job_failed(job_id, e.to_string(), jobs, database).await?;
},
}
// Release resources
{
let mut resources = available_resources.lock().await;
resources.push(resource_allocation);
}
{
let mut assignments = resource_assignments.write().await;
assignments.remove(&job_id);
}
info!("Worker {} completed job {}", worker_id, job_id);
Ok(())
}
/// Execute training using the existing ML training infrastructure
async fn execute_training(
job_id: Uuid,
config: ProductionTrainingConfig,
model_type: String,
_resource_allocation: &ResourceAllocation,
_status_broadcasters: &Arc<RwLock<HashMap<Uuid, broadcast::Sender<TrainingStatusUpdate>>>>,
) -> Result<TrainingResult> {
info!(
"Starting training execution for job {} with model type {}",
job_id, model_type
);
// Create the production training system
let training_system = ProductionMLTrainingSystem::new(config.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to create training system: {:?}", e))?;
// Load training data from configured source
// Phase 1: Configuration established, Phase 2-3 will implement actual loading
let (training_data, validation_data) = Self::load_training_data().await?;
// Execute training with progress callbacks
let result = training_system
.train_model(training_data, Some(validation_data))
.await
.map_err(|e| anyhow::anyhow!("Training failed: {:?}", e))?;
info!(
"Training completed for job {} with final loss: {:.6}",
job_id, result.final_train_loss
);
Ok(result)
}
/// Load training data from configured source with 225-feature extraction
///
/// Loads real market data from DBN files and applies full 225-feature extraction
/// (Wave C 201 features + Wave D 24 features) using FeatureExtractor.
pub async fn load_training_data() -> Result<(
Vec<(FinancialFeatures, Vec<f64>)>,
Vec<(FinancialFeatures, Vec<f64>)>,
)> {
// Primary: Try to load real DBN market data
let dbn_file_path = std::env::var("DBN_DATA_FILE").unwrap_or_else(|_| {
"test_data/real/databento/ES.FUT_ohlcv-1m_2024-01-02.dbn".to_string()
});
if std::path::Path::new(&dbn_file_path).exists() {
info!(
"📊 Loading REAL market data from DBN file with 225-feature extraction: {}",
dbn_file_path
);
match Self::load_training_data_with_225_features(&dbn_file_path, 0.8).await {
Ok((training_data, validation_data)) => {
info!(
"✅ Loaded {} training samples, {} validation samples with 225 features (Wave C + Wave D)",
training_data.len(),
validation_data.len()
);
return Ok((training_data, validation_data));
},
Err(e) => {
warn!("Failed to load DBN data with 225-feature extraction: {}, falling back to database", e);
},
}
} else {
debug!("DBN file not found at {}, trying database", dbn_file_path);
}
// Fallback: Try database loading or mock data
#[cfg(feature = "mock-data")]
{
error!("Using MOCK training data - NOT FOR PRODUCTION USE!");
error!("Rebuild without --features mock-data for production");
let training_data = Self::generate_mock_training_data()?;
let validation_data = Self::generate_mock_validation_data()?;
return Ok((training_data, validation_data));
}
#[cfg(not(feature = "mock-data"))]
{
use crate::data_config::DataSourceType;
use crate::data_loader::HistoricalDataLoader;
// Load data source configuration
let data_config = TrainingDataSourceConfig::from_env()
.map_err(|e| anyhow::anyhow!("Failed to load data source configuration: {}", e))?;
data_config
.validate()
.map_err(|e| anyhow::anyhow!("Invalid data source configuration: {}", e))?;
info!(
"📊 Training data configuration loaded: {:?}",
data_config.summary()
);
// Load data based on source type
match data_config.source_type {
DataSourceType::Historical | DataSourceType::Hybrid => {
info!("Loading historical training data from PostgreSQL");
let mut loader = HistoricalDataLoader::new(data_config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create data loader: {}", e))?;
let (training_data, validation_data) = loader
.load_training_data()
.await
.map_err(|e| anyhow::anyhow!("Failed to load training data: {}", e))?;
info!(
"✅ Loaded {} training samples, {} validation samples from database",
training_data.len(),
validation_data.len()
);
Ok((training_data, validation_data))
},
DataSourceType::RealTime => Err(anyhow::anyhow!(
"❌ RealTime data source not yet implemented (Phase 3)\n\
\n\
📋 Supported data sources:\n\
- DBN: Real market data files (Phase 2 ✅)\n\
- Historical: PostgreSQL database (Phase 2 ✅)\n\
- RealTime: Live streaming data (Phase 3 pending)\n\
- Hybrid: Historical + RealTime (Phase 3 pending)\n\
- Parquet: S3 parquet files (Phase 4 pending)\n\
\n\
🔧 Set DBN_DATA_FILE=/path/to/file.dbn for real market data\n\
Set DATA_SOURCE_TYPE=historical to use database loading\n\
Set DATABASE_URL to your PostgreSQL instance"
)),
DataSourceType::Parquet => Err(anyhow::anyhow!(
"❌ Parquet data source not yet implemented (Phase 4)\n\
\n\
📋 Supported data sources:\n\
- DBN: Real market data files (Phase 2 ✅)\n\
- Historical: PostgreSQL database (Phase 2 ✅)\n\
- Parquet: S3 parquet files (Phase 4 pending)\n\
\n\
🔧 Set DBN_DATA_FILE=/path/to/file.dbn for real market data\n\
Set DATA_SOURCE_TYPE=historical to use database loading\n\
Set DATABASE_URL to your PostgreSQL instance"
)),
}
}
}
/// Load training data from DBN file with 225-feature extraction (Wave C + Wave D)
///
/// Uses FeatureExtractor to produce all 225 features per bar:
/// - Wave C (indices 0-200): 201 features
/// - Wave D (indices 201-224): 24 regime detection features
async fn load_training_data_with_225_features(
dbn_file_path: &str,
train_split: f64,
) -> Result<(
Vec<(FinancialFeatures, Vec<f64>)>,
Vec<(FinancialFeatures, Vec<f64>)>,
)> {
use dbn::decode::{DbnDecoder, DecodeRecordRef};
use dbn::OhlcvMsg;
use ml::features::extraction::{FeatureExtractor, OHLCVBar};
use anyhow::Context;
use chrono::TimeZone;
// Load OHLCV bars from DBN file (reuse existing loader logic)
debug!("Loading DBN file for 225-feature extraction: {}", dbn_file_path);
let mut decoder = DbnDecoder::from_file(dbn_file_path)
.context(format!("Failed to create DBN decoder for file: {}", dbn_file_path))?;
let mut ohlcv_bars = Vec::new();
// Decode all OHLCV records from DBN file
while let Some(record_ref) = decoder.decode_record_ref()
.context("Failed to decode DBN record")?
{
if let Some(ohlcv_msg) = record_ref.get::<OhlcvMsg>() {
// Convert timestamp from nanoseconds
let ts_nanos = ohlcv_msg.hd.ts_event as i64;
let secs = ts_nanos / 1_000_000_000;
let nanos = (ts_nanos % 1_000_000_000) as u32;
let timestamp = chrono::Utc
.timestamp_opt(secs, nanos)
.single()
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp: {}", ts_nanos))?;
// Convert DBN fixed-point prices to f64 (9 decimal places)
let dbn_to_f64 = |price: i64| price as f64 / 1_000_000_000.0;
ohlcv_bars.push(OHLCVBar {
timestamp,
open: dbn_to_f64(ohlcv_msg.open),
high: dbn_to_f64(ohlcv_msg.high),
low: dbn_to_f64(ohlcv_msg.low),
close: dbn_to_f64(ohlcv_msg.close),
volume: ohlcv_msg.volume as f64,
});
}
}
info!("Loaded {} OHLCV bars from DBN file", ohlcv_bars.len());
// Extract 225 features using FeatureExtractor (Wave C + Wave D)
const WARMUP_PERIOD: usize = 50;
if ohlcv_bars.len() < WARMUP_PERIOD {
return Err(anyhow::anyhow!(
"Insufficient data: {} bars provided, {} required for warmup",
ohlcv_bars.len(),
WARMUP_PERIOD
));
}
let mut extractor = FeatureExtractor::new();
let mut feature_vectors_225 = Vec::with_capacity(ohlcv_bars.len() - WARMUP_PERIOD);
// Feed bars sequentially to build rolling windows
for (i, bar) in ohlcv_bars.iter().enumerate() {
extractor.update(bar).context(format!("Feature extraction failed at bar {}", i))?;
// Start extracting features after warmup
if i >= WARMUP_PERIOD {
let features_225 = extractor.extract_current_features()
.context(format!("Failed to extract features at bar {}", i))?;
feature_vectors_225.push(features_225);
}
}
info!(
"Extracted {} feature vectors (225 dimensions each, Wave C + Wave D)",
feature_vectors_225.len()
);
// Convert [f64; 225] to (FinancialFeatures, Vec<f64>) format for compatibility.
//
// This conversion exists because UnifiedTrainable::train() accepts
// Vec<(FinancialFeatures, Vec<f64>)>. The FinancialFeatures fields below are
// stubs — only the Vec<f64> component is consumed by the underlying models.
//
// To remove this layer: change UnifiedTrainable::train() to accept &[Vec<f64>]
// directly and update all 4 model adapters (DQN, PPO, TFT, Mamba2).
let training_samples: Vec<(FinancialFeatures, Vec<f64>)> = feature_vectors_225
.iter()
.zip(ohlcv_bars.iter().skip(WARMUP_PERIOD))
.map(|(features_225, bar)| {
// Create stub FinancialFeatures for compatibility
let close_price = common::Price::new(bar.close).unwrap_or_default();
let financial_features = FinancialFeatures {
timestamp: bar.timestamp,
prices: vec![close_price],
volumes: vec![bar.volume as i64],
technical_indicators: std::collections::HashMap::new(),
microstructure: ml::training_pipeline::MicrostructureFeatures {
spread_bps: 0,
imbalance: 0.0,
trade_intensity: 0.0,
vwap: close_price,
},
risk_metrics: ml::training_pipeline::RiskFeatures {
var_5pct: 0.0,
expected_shortfall: 0.0,
max_drawdown: 0.0,
sharpe_ratio: 0.0,
},
};
// Convert [f64; 225] to Vec<f64>
(financial_features, features_225.to_vec())
})
.collect();
// Split into train/validation (preserve time-series ordering)
let split_idx = (training_samples.len() as f64 * train_split) as usize;
let train_data = training_samples[..split_idx].to_vec();
let val_data = training_samples[split_idx..].to_vec();
info!(
"✅ Created {} training samples, {} validation samples (225 features each)",
train_data.len(),
val_data.len()
);
Ok((train_data, val_data))
}
/// Generate mock training data for demonstration
#[cfg(feature = "mock-data")]
fn generate_mock_training_data() -> Result<Vec<(FinancialFeatures, Vec<f64>)>> {
// This is a simplified mock implementation
// In production, this would load real market data
let mut data = Vec::new();
for i in 0..1000 {
let price = 100.0 + (i as f64 * 0.01);
let features = FinancialFeatures {
prices: vec![
common::Price::from_f64(price).unwrap_or_default()
],
volumes: vec![1000 + i as i64],
technical_indicators: [("rsi".to_string(), 0.5 + 0.3 * (i as f64 / 1000.0).sin())]
.iter()
.cloned()
.collect(),
microstructure: ml::training_pipeline::MicrostructureFeatures {
spread_bps: 10,
imbalance: 0.1 * (i as f64 / 100.0).sin(),
trade_intensity: 2.5,
vwap: common::Price::from_f64(price * 0.9995).unwrap_or_default(),
},
risk_metrics: ml::training_pipeline::RiskFeatures {
var_5pct: -0.02,
expected_shortfall: -0.03,
max_drawdown: -0.05,
sharpe_ratio: 1.2,
},
timestamp: chrono::Utc::now(),
};
let targets = vec![price * 1.001]; // Predict small price increase
data.push((features, targets));
}
Ok(data)
}
/// Generate mock validation data
#[cfg(feature = "mock-data")]
fn generate_mock_validation_data() -> Result<Vec<(FinancialFeatures, Vec<f64>)>> {
// Similar to training data but smaller
Self::generate_mock_training_data().map(|mut data| {
data.truncate(200);
data
})
}
/// Handle successful training completion
async fn handle_training_success(
job_id: Uuid,
result: TrainingResult,
jobs: &Arc<RwLock<HashMap<Uuid, TrainingJob>>>,
_database: &Arc<DatabaseManager>,
storage: &Arc<ModelStorageManager>,
) -> Result<()> {
// Serialize training result to model data (simplified)
let model_data = format!(
"{{\"final_train_loss\":{},\"final_val_loss\":{}}}",
result.final_train_loss, result.final_val_loss
)
.into_bytes();
// Attempt to store model artifact with S3 upload
let model_path = match storage.store_model(job_id, &model_data).await {
Ok(path) => {
info!(
"Successfully uploaded model for job {} to storage: {}",
job_id, path
);
path
},
Err(e) => {
error!("Failed to upload model for job {}: {}", job_id, e);
// Fallback to local path for tracking
let fallback_path = format!("models/{}.bin", job_id);
warn!("Using fallback path for job {}: {}", job_id, fallback_path);
fallback_path
},
};
// Extract accuracy from training result metrics history
let accuracy = result
.metrics_history
.last()
.map(|m| m.prediction_accuracy)
.unwrap_or(0.0);
// Create model metadata for tracking and versioning
let model_metadata = {
let job_guard = jobs.read().await;
if let Some(job) = job_guard.get(&job_id) {
config::ModelRegistryEntry {
id: job_id,
name: job.model_type.clone(),
version: format!("v{}", chrono::Utc::now().format("%Y%m%d_%H%M%S")),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
training_metrics: config::TrainingMetrics {
accuracy,
loss: result.final_train_loss,
validation_accuracy: accuracy, // Same as training accuracy for now
validation_loss: result.final_val_loss,
epochs: result.epochs_trained as u32,
training_time_seconds: result.training_duration.num_seconds() as f64,
},
architecture: config::ModelArchitecture {
model_type: job.model_type.clone(),
input_dim: job.config.model_config.input_dim,
output_dim: job.config.model_config.output_dim,
hidden_layers: job.config.model_config.hidden_dims.clone(),
activation: job.config.model_config.activation.clone(),
optimizer: "adamw".to_string(), // Standard optimizer for production ML
learning_rate: job.config.training_params.learning_rate,
},
}
} else {
return Err(anyhow::anyhow!(
"Job {} not found for metadata creation",
job_id
));
}
};
info!(
"Created model metadata for job {} with version {}",
job_id, model_metadata.version
);
// Update job status
{
let mut jobs_write = jobs.write().await;
if let Some(job) = jobs_write.get_mut(&job_id) {
job.status = JobStatus::Completed;
job.completed_at = Some(Utc::now());
job.progress_percentage = 100.0;
job.model_artifact_path = Some(model_path.clone());
job.metrics
.insert("final_train_loss".to_string(), result.final_train_loss);
job.metrics
.insert("final_val_loss".to_string(), result.final_val_loss);
job.metrics.insert(
"model_version".to_string(),
model_metadata.version.parse().unwrap_or(0.0),
);
job.metrics
.insert("model_size_bytes".to_string(), model_data.len() as f64);
}
}
info!("Training job {} completed successfully", job_id);
Ok(())
}
/// Mark job as failed
async fn mark_job_failed(
job_id: Uuid,
error_message: String,
jobs: &Arc<RwLock<HashMap<Uuid, TrainingJob>>>,
_database: &Arc<DatabaseManager>,
) -> Result<()> {
{
let mut jobs_write = jobs.write().await;
if let Some(job) = jobs_write.get_mut(&job_id) {
job.status = JobStatus::Failed;
job.completed_at = Some(Utc::now());
job.error_message = Some(error_message.clone());
}
}
error!("Training job {} failed: {}", job_id, error_message);
Ok(())
}
/// Send status update
async fn send_status_update(
job_id: Uuid,
status: JobStatus,
progress: f32,
current_epoch: u32,
total_epochs: u32,
metrics: HashMap<String, f64>,
message: String,
financial_metrics: Option<ml::training_pipeline::FinancialPerformanceMetrics>,
resource_allocation: &ResourceAllocation,
status_broadcasters: &Arc<RwLock<HashMap<Uuid, broadcast::Sender<TrainingStatusUpdate>>>>,
) {
let update = TrainingStatusUpdate {
job_id,
status,
progress_percentage: progress,
current_epoch,
total_epochs,
metrics,
message,
timestamp: Utc::now(),
financial_metrics,
resource_usage: ResourceUsage {
cpu_usage_percent: 75.0, // Mock values
memory_usage_gb: resource_allocation.memory_gb as f32,
gpu_usage_percent: resource_allocation.gpu_id.map(|_| 80.0),
gpu_memory_usage_gb: resource_allocation.gpu_id.map(|_| 4.0),
active_workers: 1,
},
};
let broadcasters = status_broadcasters.read().await;
if let Some(broadcaster) = broadcasters.get(&job_id) {
match broadcaster.send(update.clone()) {
Ok(_) => {
debug!("Status update sent successfully for job {}", job_id);
},
Err(broadcast::error::SendError(_)) => {
warn!(
"Failed to send status update for job {} - all receivers dropped. \
This indicates clients have disconnected.",
job_id
);
// Note: The broadcast channel automatically handles the case where
// receivers are lagging behind. Slow receivers will miss old messages
// but will still receive new ones, providing natural back-pressure.
},
}
} else {
debug!("No broadcaster found for job {}", job_id);
}
}
/// Broadcast status update for a job
async fn broadcast_status_update(&self, job_id: Uuid, message: String) {
let jobs = self.jobs.read().await;
if let Some(job) = jobs.get(&job_id) {
Self::send_status_update(
job_id,
job.status.clone(),
job.progress_percentage,
job.current_epoch,
job.total_epochs,
job.metrics.clone(),
message,
None,
&ResourceAllocation {
gpu_id: None,
cpu_cores: 1,
memory_gb: 1.0,
worker_id: 0,
},
&self.status_broadcasters,
)
.await;
}
}
/// Get job model type
async fn get_job_model_type(&self, job_id: Uuid) -> Option<String> {
let jobs = self.jobs.read().await;
jobs.get(&job_id).map(|job| job.model_type.clone())
}
/// Spawn resource monitor
async fn spawn_resource_monitor(&self) -> Result<tokio::task::JoinHandle<()>> {
let interval = Duration::from_secs(30);
let handle = tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
loop {
interval_timer.tick().await;
// Monitor system resources
// This is a simplified implementation
debug!("Resource monitor tick - system resources OK");
}
});
Ok(handle)
}
/// Periodically send snapshot status updates for all active jobs
///
/// This provides a fallback mechanism when streaming updates are overwhelmed
async fn spawn_snapshot_broadcaster(&self) -> Result<tokio::task::JoinHandle<()>> {
let jobs = Arc::clone(&self.jobs);
let status_broadcasters = Arc::clone(&self.status_broadcasters);
let interval = Duration::from_secs(30); // Default snapshot interval
let cancellation_token = self.cancellation_token.child_token();
let handle = tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
loop {
tokio::select! {
_ = interval_timer.tick() => {
// Send snapshot updates for all active jobs
let jobs_read = jobs.read().await;
for job in jobs_read.values() {
if matches!(job.status, JobStatus::Running | JobStatus::Pending) {
Self::send_snapshot_update(job, &status_broadcasters).await;
}
}
// Clean up disconnected broadcasters periodically
Self::cleanup_disconnected_broadcasters_static(&status_broadcasters).await;
}
_ = cancellation_token.cancelled() => {
info!("Snapshot broadcaster received cancellation signal");
break;
}
}
}
});
Ok(handle)
}
/// Send a snapshot status update for a job
async fn send_snapshot_update(
job: &TrainingJob,
status_broadcasters: &Arc<RwLock<HashMap<Uuid, broadcast::Sender<TrainingStatusUpdate>>>>,
) {
let snapshot_update = TrainingStatusUpdate {
job_id: job.id,
status: job.status.clone(),
progress_percentage: job.progress_percentage,
current_epoch: job.current_epoch,
total_epochs: job.total_epochs,
metrics: job.metrics.clone(),
message: "Snapshot status update".to_string(),
timestamp: Utc::now(),
financial_metrics: None,
resource_usage: ResourceUsage {
cpu_usage_percent: 0.0, // Snapshot doesn't have real-time resource data
memory_usage_gb: 0.0,
gpu_usage_percent: None,
gpu_memory_usage_gb: None,
active_workers: 0,
},
};
let broadcasters = status_broadcasters.read().await;
if let Some(broadcaster) = broadcasters.get(&job.id) {
// For snapshots, we use send (broadcast channels don't have try_send)
if let Err(e) = broadcaster.send(snapshot_update) {
tracing::warn!("Channel send failed (receiver dropped?): {:?}", e);
}
}
}
/// Static version of cleanup for use in spawned tasks
async fn cleanup_disconnected_broadcasters_static(
status_broadcasters: &Arc<RwLock<HashMap<Uuid, broadcast::Sender<TrainingStatusUpdate>>>>,
) {
let mut broadcasters = status_broadcasters.write().await;
let mut to_remove = Vec::new();
for (job_id, broadcaster) in broadcasters.iter() {
if broadcaster.receiver_count() == 0 {
to_remove.push(*job_id);
}
}
for job_id in to_remove {
broadcasters.remove(&job_id);
debug!("Cleaned up broadcaster for job {}", job_id);
}
}
}
/// Shutdown the orchestrator
impl Drop for TrainingOrchestrator {
fn drop(&mut self) {
info!("Shutting down training orchestrator");
// Cancel all worker tasks
for handle in self.worker_handles.drain(..) {
handle.abort();
}
}
}