Files
foxhunt/crates/ml/src/hyperopt/adapters/tlob.rs
jgrusewski 09c515e3e9 fix(clippy): ZERO errors across entire workspace — CI ready
Final 31 ml crate fixes: unsafe_code allows, unused vars prefixed,
boolean simplification, dead code removal, integer suffix, drop cleanup.

cargo fix auto-removed ~30 unused imports from ml crate.

Total clippy cleanup: 278 errors → 0 across all ML crates.
Full workspace: `cargo clippy --workspace --lib -- -D warnings` = 0 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:04:09 +01:00

547 lines
20 KiB
Rust

//! TLOB (Time Limit Order Book) Hyperparameter Optimization Adapter
//!
//! Provides `TLOBParams` (implementing `ParameterSpace`), `TLOBMetrics`,
//! and `TLOBTrainer` (implementing `HyperparameterOptimizable`) for
//! hyperparameter optimization of the TLOB model via the unified framework.
use std::sync::Arc;
use ml_core::device::MlDevice;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing::{info, warn};
use crate::features::extract_ml_features;
use crate::features::extraction::OHLCVBar;
use crate::hyperopt::paths::TrainingPaths;
use crate::hyperopt::traits::{HardwareBudget, HyperparameterOptimizable, ParameterSpace};
use crate::tlob::trainable_adapter::{TLOBAdapterConfig, TLOBTrainableAdapter};
use crate::training::unified_trainer::UnifiedTrainable;
use crate::MLError;
/// TLOB transformer model overhead in MB (weights + optimizer state)
const MODEL_OVERHEAD_MB: f64 = 60.0;
/// TLOB per-sample memory in MB (LOB depth x features x seq)
const MB_PER_SAMPLE: f64 = 0.02;
/// Hyperparameters for TLOB hyperopt tuning.
///
/// Controls the TLOB transformer's architecture and training:
/// - Architecture: `d_model`, `num_heads`, `num_layers`, `seq_len`
/// - Regularization: `dropout`, `weight_decay`, `grad_clip`
/// - Training: `learning_rate`, `batch_size`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TLOBParams {
/// Learning rate for AdamW optimizer (log-scale)
pub learning_rate: f64,
/// Model / hidden dimension for projection layers
pub d_model: usize,
/// Number of attention heads
pub num_heads: usize,
/// Number of transformer/projection layers
pub num_layers: usize,
/// Sequence length (number of time steps in LOB snapshot)
pub seq_len: usize,
/// Dropout rate for regularization
pub dropout: f64,
/// Training batch size
pub batch_size: usize,
/// L2 weight decay (log-scale)
pub weight_decay: f64,
/// Maximum gradient norm for clipping (log-scale)
pub grad_clip: f64,
/// Signal high threshold in basis points (GPU backtest action mapping)
pub signal_high_bps: f64,
/// Signal low threshold in basis points (GPU backtest action mapping)
pub signal_low_bps: f64,
}
impl Default for TLOBParams {
fn default() -> Self {
Self {
learning_rate: 1e-3,
d_model: 128,
num_heads: 4,
num_layers: 2,
seq_len: 128,
dropout: 0.1,
batch_size: 32,
weight_decay: 1e-4,
grad_clip: 1.0,
signal_high_bps: 10.0,
signal_low_bps: 5.0,
}
}
}
/// Number of continuous parameters in the TLOB parameter space.
const NUM_PARAMS: usize = 11;
impl ParameterSpace for TLOBParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
vec![
(1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log)
(64.0, 1024.0), // d_model (linear)
(2.0, 16.0), // num_heads (linear)
(1.0, 8.0), // num_layers (linear)
(32.0, 256.0), // seq_len (linear)
(0.0, 0.5), // dropout (linear)
(8.0, 512.0), // batch_size (linear)
(1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log)
(0.5_f64.ln(), 5.0_f64.ln()), // grad_clip (log)
(1.0, 30.0), // signal_high_bps
(0.5, 15.0), // signal_low_bps
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() != NUM_PARAMS {
return Err(MLError::ConfigError(format!("Expected {} continuous parameters, got {}", NUM_PARAMS, x.len())));
}
Ok(Self {
learning_rate: x.first().copied().unwrap_or(0.0).exp(),
d_model: x.get(1).copied().unwrap_or(128.0).round().max(64.0) as usize,
num_heads: x.get(2).copied().unwrap_or(4.0).round().max(2.0) as usize,
num_layers: x.get(3).copied().unwrap_or(2.0).round().max(1.0) as usize,
seq_len: x.get(4).copied().unwrap_or(128.0).round().max(32.0) as usize,
dropout: x.get(5).copied().unwrap_or(0.1).clamp(0.0, 0.5),
batch_size: x.get(6).copied().unwrap_or(32.0).round().max(8.0) as usize,
weight_decay: x.get(7).copied().unwrap_or(0.0).exp(),
grad_clip: x.get(8).copied().unwrap_or(0.0).exp(),
signal_high_bps: x.get(9).copied().unwrap_or(10.0).clamp(1.0, 30.0),
signal_low_bps: x.get(10).copied().unwrap_or(5.0).clamp(0.5, 15.0),
})
}
fn to_continuous(&self) -> Vec<f64> {
vec![
self.learning_rate.ln(),
self.d_model as f64,
self.num_heads as f64,
self.num_layers as f64,
self.seq_len as f64,
self.dropout,
self.batch_size as f64,
self.weight_decay.ln(),
self.grad_clip.ln(),
self.signal_high_bps,
self.signal_low_bps,
]
}
fn param_names() -> Vec<&'static str> {
vec![
"learning_rate",
"d_model",
"num_heads",
"num_layers",
"seq_len",
"dropout",
"batch_size",
"weight_decay",
"grad_clip",
"signal_high_bps",
"signal_low_bps",
]
}
fn continuous_bounds_for(budget: &HardwareBudget) -> Vec<(f64, f64)> {
let mut bounds = Self::continuous_bounds();
if let Some(max_batch) = budget.max_batch_size(MODEL_OVERHEAD_MB, MB_PER_SAMPLE, 8.0, 4096.0) {
if let Some(batch_bound) = bounds.get_mut(6) {
batch_bound.1 = max_batch;
}
}
// Cap d_model by VRAM (index 1)
if budget.gpu_memory_mb < 8000 {
bounds[1] = (64.0, 128.0);
} else if budget.gpu_memory_mb < 16000 {
bounds[1] = (64.0, 256.0);
} else if budget.gpu_memory_mb < 40000 {
bounds[1] = (64.0, 512.0);
} else {
// ≥40GB (L40S 46GB, H100 80GB): allow full 1024
}
bounds
}
}
/// Metrics returned from TLOB hyperopt training.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TLOBMetrics {
/// Validation loss (primary objective for optimization)
pub val_loss: f64,
/// Training loss at end of run
pub train_loss: f64,
/// Directional accuracy on validation set
pub directional_accuracy: f64,
/// Number of epochs completed
pub epochs_completed: usize,
/// GPU walk-forward backtest Sharpe ratio
pub backtest_sharpe: Option<f32>,
/// GPU walk-forward backtest total trades
pub backtest_trades: Option<u32>,
}
/// TLOB trainer for hyperparameter optimization.
///
/// Wraps the `TLOBTrainableAdapter` (which implements `UnifiedTrainable`)
/// and drives it through the `HyperparameterOptimizable` interface.
/// TLOB accepts sequence input of shape `(1, seq_len, feature_dim)`.
#[derive(Debug)]
pub struct TLOBTrainer {
data_dir: PathBuf,
epochs: usize,
device: MlDevice,
training_paths: TrainingPaths,
early_stopping_patience: usize,
trial_counter: usize,
preloaded_bars: Option<Arc<[OHLCVBar]>>,
}
impl TLOBTrainer {
/// Create a new TLOB trainer.
///
/// # Arguments
/// * `data_dir` - Directory containing .dbn/.dbn.zst files
/// * `epochs` - Number of training epochs per trial
pub fn new<P: Into<PathBuf>>(data_dir: P, epochs: usize) -> Result<Self, MLError> {
let data_dir = data_dir.into();
if !data_dir.exists() {
return Err(MLError::ConfigError(format!("Data directory not found: {}", data_dir.display())));
}
let device = MlDevice::cuda(0)
.map_err(|e| MLError::ConfigError(format!("CUDA GPU required for TLOB hyperopt: {}", e)))?;
info!(
"TLOB Trainer initialized: Device={:?}, Data={}, Epochs={}",
device, data_dir.display(), epochs
);
Ok(Self {
data_dir,
epochs,
device,
training_paths: TrainingPaths::new("/tmp/ml_training", "tlob", "default"),
early_stopping_patience: 10,
trial_counter: 0,
preloaded_bars: None,
})
}
/// Set training paths configuration.
pub fn with_training_paths(mut self, paths: TrainingPaths) -> Self {
self.training_paths = paths;
self
}
/// Configure early stopping patience.
pub fn with_early_stopping(mut self, patience: usize) -> Self {
self.early_stopping_patience = patience;
self
}
/// Preload DBN data once for reuse across all hyperopt trials.
pub fn preload_data(&mut self) -> Result<(), MLError> {
if self.preloaded_bars.is_some() {
return Ok(());
}
let bars = super::dbn_loader::load_bars_from_dbn_dir(&self.data_dir)
.map_err(|e| MLError::ModelError(format!("Failed to preload DBN data: {}", e)))?;
info!("Preloaded {} OHLCV bars for reuse across trials", bars.len());
self.preloaded_bars = Some(Arc::from(bars));
Ok(())
}
/// Check if data has been preloaded.
pub fn has_preloaded_data(&self) -> bool {
self.preloaded_bars.is_some()
}
}
impl HyperparameterOptimizable for TLOBTrainer {
type Params = TLOBParams;
type Metrics = TLOBMetrics;
fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
let trial_start = std::time::Instant::now();
let current_trial = self.trial_counter;
self.trial_counter += 1;
info!(
"Training TLOB: lr={:.6}, d_model={}, heads={}, layers={}, seq_len={}, batch={}",
params.learning_rate, params.d_model, params.num_heads,
params.num_layers, params.seq_len, params.batch_size
);
self.training_paths.create_all().map_err(|e| {
MLError::ModelError(format!("Failed to create training directories: {}", e))
})?;
let bars = if let Some(ref preloaded) = self.preloaded_bars {
preloaded.to_vec() // cpu-side Arc<[OHLCVBar]> clone
} else {
super::dbn_loader::load_bars_from_dbn_dir(&self.data_dir)
.map_err(|e| MLError::ModelError(format!("Failed to load DBN data: {}", e)))?
};
let features = extract_ml_features(&bars)
.map_err(|e| MLError::ModelError(format!("Failed to extract features: {}", e)))?;
let feature_dim = 51;
let seq_len = params.seq_len;
// TLOB uses sequence input: (1, seq_len, feature_dim)
let pairs = crate::hyperopt::shared_data::build_sequence_pairs(
&features, &bars, feature_dim, seq_len, &self.device,
)?;
let split = (pairs.len() as f64 * 0.8) as usize;
let train_data = &pairs[..split];
let val_data = &pairs[split..];
if val_data.len() < 2 {
return Err(MLError::ModelError("Validation set too small".to_owned()));
}
let config = TLOBAdapterConfig {
d_model: params.d_model,
num_heads: params.num_heads,
num_layers: params.num_layers,
seq_len,
feature_dim,
};
let training_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<TLOBMetrics, MLError> {
let mut model = TLOBTrainableAdapter::new(config)?;
let mut best_val_loss = f64::MAX;
let mut patience_counter = 0_usize;
let mut last_train_loss = 0.0_f64;
for epoch in 0..self.epochs {
let mut loss_accum = 0.0_f64;
let mut batch_count = 0_usize;
let stream = self.device.cuda_stream()
.map_err(|e| MLError::DeviceError(format!("CUDA stream: {e}")))?;
for (input, target) in train_data {
let input_host = input.to_host(stream)?;
let target_host = target.to_host(stream)?;
let loss_val = model.forward_loss(&input_host, &target_host)?;
if batch_count == 0 && (loss_val.is_nan() || loss_val.is_infinite()) {
return Ok(TLOBMetrics {
val_loss: 1000.0,
train_loss: 1000.0,
directional_accuracy: 0.0,
epochs_completed: epoch,
backtest_sharpe: None,
backtest_trades: None,
});
}
model.backward(loss_val)?;
model.optimizer_step()?;
loss_accum += loss_val;
batch_count += 1;
}
last_train_loss = if batch_count > 0 {
loss_accum / batch_count as f64
} else {
0.0
};
let mut vl_accum = 0.0_f64;
let mut vl_count = 0_usize;
for (vi, vt) in val_data {
let vi_h = vi.to_host(stream)?;
let vt_h = vt.to_host(stream)?;
if let Ok(vl) = model.forward_loss(&vi_h, &vt_h) {
vl_accum += vl;
vl_count += 1;
}
}
let val_loss = if vl_count > 0 { vl_accum / vl_count as f64 } else { 1000.0 };
if val_loss < best_val_loss {
best_val_loss = val_loss;
patience_counter = 0;
} else {
patience_counter += 1;
}
if epoch % 10 == 0 {
info!(
" Epoch {}/{}: train_loss={:.6}, val_loss={:.6}",
epoch + 1, self.epochs, last_train_loss, val_loss
);
}
if patience_counter >= self.early_stopping_patience {
info!("Early stopping at epoch {}", epoch + 1);
return Ok(TLOBMetrics {
val_loss: best_val_loss,
train_loss: last_train_loss,
directional_accuracy: 0.0,
epochs_completed: epoch + 1,
backtest_sharpe: None,
backtest_trades: None,
});
}
}
Ok(TLOBMetrics {
val_loss: best_val_loss,
train_loss: last_train_loss,
directional_accuracy: 0.0,
epochs_completed: self.epochs,
backtest_sharpe: None,
backtest_trades: None,
})
}));
let metrics = match training_result {
Ok(Ok(m)) => m,
Ok(Err(e)) => {
warn!("TLOB training failed: {}", e);
TLOBMetrics { val_loss: 1000.0, train_loss: 1000.0, directional_accuracy: 0.0, epochs_completed: 0, backtest_sharpe: None, backtest_trades: None }
}
Err(_) => {
warn!("TLOB training panicked (likely OOM)");
TLOBMetrics { val_loss: 1000.0, train_loss: 1000.0, directional_accuracy: 0.0, epochs_completed: 0, backtest_sharpe: None, backtest_trades: None }
}
};
info!(
"TLOB training completed: val_loss={:.6}, train_loss={:.6}, epochs={}",
metrics.val_loss, metrics.train_loss, metrics.epochs_completed
);
let duration_secs = trial_start.elapsed().as_secs_f64();
let trial_result = crate::hyperopt::traits::TrialResult {
trial_num: current_trial,
params,
objective: Self::extract_objective(&metrics),
duration_secs,
metrics: None,
};
std::fs::create_dir_all(self.training_paths.hyperopt_dir()).ok();
crate::hyperopt::shared_data::write_trial_result_json(
&self.training_paths.hyperopt_dir(),
&trial_result,
).ok();
if self.device.is_cuda() {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(metrics)
}
fn extract_objective(metrics: &Self::Metrics) -> f64 {
if let (Some(sharpe), Some(trades)) = (metrics.backtest_sharpe, metrics.backtest_trades) {
return crate::cuda_pipeline::signal_adapter::backtest_fitness(sharpe, trades, 30, 0.0);
}
metrics.val_loss
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounds_count_matches_param_names() {
let bounds = TLOBParams::continuous_bounds();
let names = TLOBParams::param_names();
assert_eq!(bounds.len(), names.len());
assert_eq!(bounds.len(), NUM_PARAMS);
}
#[test]
fn test_roundtrip_continuous() {
let params = TLOBParams::default();
let continuous = params.to_continuous();
let restored = TLOBParams::from_continuous(&continuous).unwrap();
assert!(
(params.learning_rate - restored.learning_rate).abs() < 1e-6,
"lr mismatch: {} vs {}",
params.learning_rate,
restored.learning_rate
);
assert_eq!(params.d_model, restored.d_model);
assert_eq!(params.num_heads, restored.num_heads);
assert_eq!(params.num_layers, restored.num_layers);
assert_eq!(params.seq_len, restored.seq_len);
assert!((params.dropout - restored.dropout).abs() < 1e-6, "dropout mismatch");
assert_eq!(params.batch_size, restored.batch_size);
assert!(
(params.weight_decay - restored.weight_decay).abs() / params.weight_decay < 1e-6,
"weight_decay mismatch"
);
assert!(
(params.grad_clip - restored.grad_clip).abs() < 1e-6,
"grad_clip mismatch"
);
}
#[test]
fn test_from_continuous_wrong_length_errors() {
let result = TLOBParams::from_continuous(&[0.1, 0.2]);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("Expected 11"), "Error should mention expected count: {}", err_msg);
}
#[test]
fn test_bounds_are_valid() {
for (i, (min, max)) in TLOBParams::continuous_bounds().iter().enumerate() {
assert!(
min < max,
"Invalid bounds for param {} ({}): {} >= {}",
i,
TLOBParams::param_names().get(i).copied().unwrap_or("?"),
min,
max
);
}
}
#[test]
fn test_default_within_bounds() {
let params = TLOBParams::default();
let continuous = params.to_continuous();
let bounds = TLOBParams::continuous_bounds();
let names = TLOBParams::param_names();
for (i, (val, (min, max))) in continuous.iter().zip(bounds.iter()).enumerate() {
assert!(
*val >= *min && *val <= *max,
"Param {} ({}) = {} outside [{}, {}]",
i,
names.get(i).copied().unwrap_or("?"),
val,
min,
max
);
}
}
#[test]
fn test_metrics_default() {
let metrics = TLOBMetrics::default();
assert_eq!(metrics.val_loss, 0.0);
assert_eq!(metrics.train_loss, 0.0);
assert_eq!(metrics.directional_accuracy, 0.0);
assert_eq!(metrics.epochs_completed, 0);
}
#[test]
fn test_continuous_length() {
let params = TLOBParams::default();
let continuous = params.to_continuous();
assert_eq!(continuous.len(), NUM_PARAMS);
}
}