Files
foxhunt/crates/ml/tests/tft_real_dbn_data_test.rs
jgrusewski 5d6e79263c fix: migrate remaining 8 test files to GPU types — all test errors fixed
tft_real_dbn_data: StreamTensor::from_vec, quantile loss returns f32
ppo_recurrent_integration: PPO::new() API, get_policy_state &[f32]
test_dbn_sequence_256: to_host + manual indexing instead of .i() ops
ppo_checkpoint_roundtrip: save/load_checkpoint(&PathBuf) API
mamba2_accuracy_fix: pure f64 arithmetic, no GPU tensors needed
ppo_lstm_training_loop: PPO::new() API
ppo_step_counter_fix: new checkpoint API
ppo_recurrent_performance: forward_host, LSTM batch_size arg

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

845 lines
29 KiB
Rust

#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! TFT Training with Real DBN Market Data - Wave 8.13
//!
//! Validates TFT (Temporal Fusion Transformer) trains successfully on real E-mini S&P 500
//! futures data from DataBento DBN files. Tests complete data pipeline from DBN loading
//! to multi-horizon forecasting with quantile uncertainty estimation.
//!
//! ## Test Coverage
//!
//! 1. **DBN Data Loading**: Load ES.FUT OHLCV bars from binary DBN files
//! 2. **Feature Extraction**: Convert to TFT format (static, historical, future)
//! 3. **Model Initialization**: TFT with variable selection + attention + quantile layers
//! 4. **Forward Pass**: Complete architecture validation
//! 5. **Loss Computation**: Quantile loss for uncertainty quantification
//! 6. **Training Loop**: 10 epochs with gradient flow
//! 7. **Loss Convergence**: Verify loss decreases >10% over training
//! 8. **Inference**: Multi-horizon predictions with confidence intervals
//!
//! ## Data Source
//!
//! - **Symbol**: ES.FUT (E-mini S&P 500 Futures)
//! - **Location**: `test_data/real/databento/ml_training/ES.FUT_ohlcv-1m_2024-03-25.dbn`
//! - **Frequency**: 1-minute OHLCV bars
//! - **Bars Expected**: 1000+ bars for training
//!
//! ## Usage
//!
//! ```bash
//! # Run single test
//! cargo test -p ml test_tft_with_real_dbn_data -- --nocapture --test-threads=1
//!
//! # Run all TFT DBN tests
//! cargo test -p ml tft_real_dbn -- --nocapture --test-threads=1
//! ```
use anyhow::{Context, Result};
// candle eliminated — test uses native APIs
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
use cudarc::driver::{CudaContext, CudaStream};
use dbn::decode::{DbnDecoder, DecodeRecordRef};
use dbn::OhlcvMsg;
use ml_core::cuda_autograd::stream_ops::StreamTensor;
use ndarray::{Array1, Array2};
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
use tracing::warn;
use ml::tft::{TFTConfig, TemporalFusionTransformer};
/// OHLCV bar structure for intermediate data processing
#[derive(Debug, Clone)]
struct OhlcvBar {
timestamp: DateTime<Utc>,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
}
/// Load OHLCV bars from DBN file with automatic price anomaly correction
///
/// Implements same price correction logic as backtesting service:
/// - Detects 100x encoding errors (prices < 1000 with >50% change)
/// - Applies 100x correction when result is in valid range (3000-6000)
/// - Skips corrupted bars that can't be corrected
async fn load_dbn_ohlcv_bars(file_path: &str) -> Result<Vec<OhlcvBar>> {
let mut decoder = DbnDecoder::from_file(file_path)
.context(format!("Failed to create DBN decoder for: {}", file_path))?;
let mut bars = Vec::new();
let mut prev_close: Option<f64> = None;
let mut corrections_applied = 0;
while let Some(record_ref) = decoder
.decode_record_ref()
.context("Failed to decode DBN record")?
{
if let Some(ohlcv) = record_ref.get::<OhlcvMsg>() {
// Convert timestamp (nanoseconds since epoch)
let ts_nanos = ohlcv.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 = Utc
.timestamp_opt(secs, nanos)
.single()
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp: {}", ts_nanos))?;
// Convert prices (DBN uses 9 decimal places)
let mut open_f64 = ohlcv.open as f64 / 1_000_000_000.0;
let mut high_f64 = ohlcv.high as f64 / 1_000_000_000.0;
let mut low_f64 = ohlcv.low as f64 / 1_000_000_000.0;
let mut close_f64 = ohlcv.close as f64 / 1_000_000_000.0;
// Price anomaly detection and correction
if let Some(prev) = prev_close {
let pct_change = ((close_f64 - prev) / prev).abs();
// Detect 100x encoding errors
if pct_change > 0.5 && close_f64 < 1000.0 {
let corrected_close = close_f64 * 100.0;
// Validate corrected price is in ES.FUT range
if corrected_close >= 3000.0 && corrected_close <= 6000.0 {
open_f64 *= 100.0;
high_f64 *= 100.0;
low_f64 *= 100.0;
close_f64 = corrected_close;
corrections_applied += 1;
} else {
// Skip corrupted bar
prev_close = Some(prev);
continue;
}
}
}
prev_close = Some(close_f64);
let bar = OhlcvBar {
timestamp,
open: open_f64,
high: high_f64,
low: low_f64,
close: close_f64,
volume: ohlcv.volume as f64,
};
bars.push(bar);
}
}
if corrections_applied > 0 {
info!(
corrections = corrections_applied,
"Applied price corrections for encoding inconsistencies"
);
}
Ok(bars)
}
/// Convert OHLCV bars to TFT data format
///
/// TFT expects three types of features:
/// - **Static features** (10): Symbol metadata, statistics, market regime
/// - **Historical features** (60 x 50): Past OHLCV + technical indicators
/// - **Future features** (horizon x 10): Known future events (calendar)
/// - **Targets** (horizon): Multi-horizon price forecast
fn convert_to_tft_data(
bars: &[OhlcvBar],
lookback_window: usize,
forecast_horizon: usize,
) -> Result<Vec<(Array1<f64>, Array2<f64>, Array2<f64>, Array1<f64>)>> {
if bars.len() < lookback_window + forecast_horizon {
return Err(anyhow::anyhow!(
"Insufficient data: need {} bars, got {}",
lookback_window + forecast_horizon,
bars.len()
));
}
let mut tft_samples = Vec::new();
// Calculate global statistics for normalization
let prices: Vec<f64> = bars.iter().map(|b| b.close).collect();
let mean_price = prices.iter().sum::<f64>() / prices.len() as f64;
let price_std =
(prices.iter().map(|p| (p - mean_price).powi(2)).sum::<f64>() / prices.len() as f64).sqrt();
let volumes: Vec<f64> = bars.iter().map(|b| b.volume).collect();
let mean_volume = volumes.iter().sum::<f64>() / volumes.len() as f64;
let volume_std = (volumes
.iter()
.map(|v| (v - mean_volume).powi(2))
.sum::<f64>()
/ volumes.len() as f64)
.sqrt();
// Create sliding windows
for i in 0..bars.len() - lookback_window - forecast_horizon + 1 {
// Static features (10): Symbol metadata and market statistics
let first_bar = &bars[i];
let hour = first_bar.timestamp.hour() as f64;
let day_of_week = first_bar.timestamp.weekday().num_days_from_monday() as f64;
let is_morning = if hour < 12.0 { 1.0 } else { 0.0 };
let is_afternoon = if hour >= 12.0 && hour < 17.0 {
1.0
} else {
0.0
};
// Calculate volatility over lookback window
let lookback_slice = &bars[i..i + lookback_window];
let returns: Vec<f64> = lookback_slice
.windows(2)
.map(|w| (w[1].close / w[0].close).ln())
.collect();
let volatility = if returns.len() > 1 {
let mean_return = returns.iter().sum::<f64>() / returns.len() as f64;
(returns
.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f64>()
/ returns.len() as f64)
.sqrt()
} else {
0.01
};
let liquidity = mean_volume / mean_price; // Simple liquidity proxy
let static_features = Array1::from_vec(vec![
mean_price / 5000.0, // Normalize around ES.FUT price (~4500-5500)
price_std / 100.0,
mean_volume / 1000.0,
volume_std / 1000.0,
hour / 24.0,
day_of_week / 7.0,
is_morning,
is_afternoon,
volatility * 100.0, // Scale for numerical stability
liquidity / 100.0,
]);
// Historical features (lookback_window x 50): OHLCV + technical indicators
let mut hist_features = Vec::new();
for t in 0..lookback_window {
let bar = &bars[i + t];
let prev_bar = if t > 0 { &bars[i + t - 1] } else { bar };
// Basic OHLCV (normalized)
let open = bar.open / mean_price;
let high = bar.high / mean_price;
let low = bar.low / mean_price;
let close = bar.close / mean_price;
let volume = bar.volume / mean_volume;
// Derived features
let returns = ((bar.close / prev_bar.close).ln() * 100.0)
.min(10.0)
.max(-10.0);
let spread = (bar.high - bar.low) / bar.close;
let body = (bar.close - bar.open) / bar.close;
// Simple moving averages
let sma_5 = if t >= 4 {
let sum: f64 = (0..5).map(|j| bars[i + t - j].close).sum();
sum / 5.0 / mean_price
} else {
close
};
let sma_20 = if t >= 19 {
let sum: f64 = (0..20).map(|j| bars[i + t - j].close).sum();
sum / 20.0 / mean_price
} else {
close
};
// RSI (14-period)
let rsi_14 = if t >= 14 {
let recent_returns: Vec<f64> = (1..=14)
.map(|j| (bars[i + t - j + 1].close / bars[i + t - j].close).ln())
.collect();
let gains: f64 = recent_returns.iter().filter(|r| **r > 0.0).sum();
let losses: f64 = recent_returns
.iter()
.filter(|r| **r < 0.0)
.map(|r| -r)
.sum();
if losses < 1e-10 {
100.0
} else {
let rs = gains / losses;
100.0 - (100.0 / (1.0 + rs))
}
} else {
50.0 // Neutral RSI
} / 100.0; // Normalize to [0, 1]
// MACD (simplified: close - SMA_20)
let macd = (close - sma_20) / sma_20;
// Volatility measures
let vol_5 = if t >= 5 {
let recent_returns: Vec<f64> = (1..=5)
.map(|j| (bars[i + t - j + 1].close / bars[i + t - j].close).ln())
.collect();
let mean = recent_returns.iter().sum::<f64>() / recent_returns.len() as f64;
(recent_returns
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ recent_returns.len() as f64)
.sqrt()
* 100.0
} else {
0.01
};
// Combine features (50 total per timestep)
let mut features = vec![
open,
high,
low,
close,
volume,
returns,
spread,
body,
sma_5,
sma_20,
rsi_14,
macd,
vol_5,
volatility * 100.0,
];
// Pad to 50 features with derived metrics
while features.len() < 50 {
let idx = features.len();
match idx {
14 => features.push(close / sma_5 - 1.0), // Price vs SMA_5
15 => features.push(close / sma_20 - 1.0), // Price vs SMA_20
16 => features.push(spread * volume), // Spread-volume
17 => features.push(returns * volume), // Return-volume
18 => features.push(high / sma_20 - 1.0), // High vs SMA
19 => features.push(low / sma_20 - 1.0), // Low vs SMA
20 => features.push(rsi_14 - 0.5), // RSI deviation
21 => features.push(body * volume), // Body-volume
22 => features.push(returns.abs()), // Absolute returns
23 => features.push(hour / 24.0), // Hour of day
24 => features.push(day_of_week / 7.0), // Day of week
_ => features.push(0.0),
}
}
hist_features.extend(features);
}
let historical_features = Array2::from_shape_vec((lookback_window, 50), hist_features)?;
// Future features (forecast_horizon x 10): Known future calendar events
let mut fut_features = Vec::new();
for t in 0..forecast_horizon {
let future_bar = &bars[i + lookback_window + t];
let fut_hour = future_bar.timestamp.hour() as f64;
let fut_day = future_bar.timestamp.weekday().num_days_from_monday() as f64;
let is_weekend = if fut_day >= 5.0 { 1.0 } else { 0.0 };
let fut_is_morning = if fut_hour < 12.0 { 1.0 } else { 0.0 };
let fut_is_afternoon = if fut_hour >= 12.0 && fut_hour < 17.0 {
1.0
} else {
0.0
};
let week_of_month = ((future_bar.timestamp.day() - 1) / 7) as f64;
let month = future_bar.timestamp.month() as f64;
let quarter = ((month - 1.0) / 3.0).floor();
let is_month_start = if future_bar.timestamp.day() <= 5 {
1.0
} else {
0.0
};
let is_month_end = if future_bar.timestamp.day() >= 25 {
1.0
} else {
0.0
};
fut_features.extend(vec![
fut_hour / 24.0,
fut_day / 7.0,
is_weekend,
fut_is_morning,
fut_is_afternoon,
week_of_month / 4.0,
month / 12.0,
quarter / 4.0,
is_month_start,
is_month_end,
]);
}
let future_features = Array2::from_shape_vec((forecast_horizon, 10), fut_features)?;
// Targets: Multi-horizon price forecast (normalized)
let targets: Vec<f64> = (0..forecast_horizon)
.map(|t| bars[i + lookback_window + t].close / mean_price)
.collect();
let target_array = Array1::from_vec(targets);
tft_samples.push((
static_features,
historical_features,
future_features,
target_array,
));
}
Ok(tft_samples)
}
/// Create TFT configuration for testing
fn create_test_tft_config() -> TFTConfig {
TFTConfig {
input_dim: 60, // Historical features (50 + 10 future)
hidden_dim: 64, // Smaller for fast testing
num_heads: 4, // Multi-head attention
num_layers: 2, // Lightweight architecture
prediction_horizon: 5, // 5-step ahead forecast
sequence_length: 60, // 60-bar lookback
num_quantiles: 9, // 9 quantiles [0.1, 0.2, ..., 0.9]
num_static_features: 10, // Symbol metadata
num_known_features: 10, // Future calendar features
num_unknown_features: 40, // 10 + 10 + 40 = 60 (fixed feature count mismatch) - Historical OHLCV + indicators
learning_rate: 0.001,
batch_size: 8,
dropout_rate: 0.1,
l2_regularization: 0.0001,
use_flash_attention: false, // Disable for compatibility
memory_efficient: true,
max_inference_latency_us: 50,
target_throughput_pps: 100_000,
}
}
#[tokio::test]
async fn test_tft_with_real_dbn_data() -> Result<()> {
info!("Wave 8.13: TFT Training with Real DBN Market Data");
// Step 1: Load real ES.FUT data from DBN file
info!("Step 1: Loading real market data from DataBento...");
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let dbn_path =
workspace_root.join("test_data/real/databento/ml_training/ES.FUT_ohlcv-1m_2024-03-25.dbn");
if !dbn_path.exists() {
warn!(path = ?dbn_path, "DBN file not found, skipping test");
return Ok(());
}
let bars = load_dbn_ohlcv_bars(dbn_path.to_str().unwrap())
.await
.context("Failed to load DBN data")?;
info!(bar_count = bars.len(), "Loaded OHLCV bars");
assert!(
bars.len() >= 100,
"Need at least 100 bars for training, got {}",
bars.len()
);
// Validate price range (ES.FUT typically 3000-6000)
let prices: Vec<f64> = bars.iter().map(|b| b.close).collect();
let min_price = prices.iter().cloned().fold(f64::INFINITY, f64::min);
let max_price = prices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
info!(min_price, max_price, "Price range");
assert!(
min_price > 1000.0 && max_price < 10000.0,
"Price range validation failed: ${:.2} - ${:.2}",
min_price,
max_price
);
// Step 2: Convert to TFT data format
info!("Step 2: Converting to TFT data format...");
let lookback_window = 60;
let forecast_horizon = 5;
let tft_data = convert_to_tft_data(&bars, lookback_window, forecast_horizon)
.context("Failed to convert to TFT format")?;
info!(sample_count = tft_data.len(), "Created TFT samples");
assert!(
tft_data.len() > 10,
"Need at least 10 samples for training, got {}",
tft_data.len()
);
// Validate data shapes
let (static_feat, hist_feat, fut_feat, targets) = &tft_data[0];
info!(
static_shape = ?static_feat.shape(),
hist_shape = ?hist_feat.shape(),
fut_shape = ?fut_feat.shape(),
target_shape = ?targets.shape(),
"Feature shapes"
);
assert_eq!(static_feat.len(), 10, "Static features should be 10-dim");
assert_eq!(
hist_feat.shape(),
&[60, 50],
"Historical features should be [60, 50]"
);
assert_eq!(
fut_feat.shape(),
&[5, 10],
"Future features should be [5, 10]"
);
assert_eq!(targets.len(), 5, "Targets should be 5-dim");
// Step 3: Initialize TFT model
info!("Step 3: Initializing TFT model...");
let stream: Arc<CudaStream> = CudaContext::new(0)
.and_then(|ctx| ctx.new_stream())
.expect("CUDA required");
info!("Device: CUDA:0");
let config = create_test_tft_config();
let mut model = TemporalFusionTransformer::new(config.clone())?;
info!(
hidden_dim = config.hidden_dim,
num_heads = config.num_heads,
num_layers = config.num_layers,
"Model created"
);
// Step 4: Training loop (10 epochs)
info!("Step 4: Training for 10 epochs...");
let epochs = 10;
let mut loss_history = Vec::new();
// Split train/val (80/20)
let split_idx = (tft_data.len() as f32 * 0.8) as usize;
let train_set = &tft_data[..split_idx];
let val_set = &tft_data[split_idx..];
info!(train_count = train_set.len(), val_count = val_set.len(), "Train/val split");
for epoch in 0..epochs {
let mut epoch_loss = 0.0;
let mut batch_count = 0;
// Training loop
for (static_feat, hist_feat, fut_feat, targets) in train_set.iter() {
// Convert ndarray to Tensor
let static_data: Vec<f32> = static_feat.iter().map(|&x| x as f32).collect();
let static_tensor = StreamTensor::from_vec(static_data.clone(), &[1, 10], &stream)?;
let hist_data: Vec<f32> = hist_feat.iter().map(|&x| x as f32).collect();
let hist_tensor = StreamTensor::from_vec(hist_data.clone(), &[1, 60, 50], &stream)?;
let fut_data: Vec<f32> = fut_feat.iter().map(|&x| x as f32).collect();
let fut_tensor = StreamTensor::from_vec(fut_data.clone(), &[1, 5, 10], &stream)?;
let target_data: Vec<f32> = targets.iter().map(|&x| x as f32).collect();
let target_tensor = StreamTensor::from_vec(target_data.clone(), &[1, 5], &stream)?;
// Forward pass
let predictions = model.forward(&static_tensor, &hist_tensor, &fut_tensor)?;
// Compute quantile loss
let loss = model.compute_quantile_loss(&predictions, &target_tensor)?;
let loss_value = loss as f64;
epoch_loss += loss_value;
batch_count += 1;
// Note: Actual gradient updates would go here with optimizer
}
let avg_train_loss = epoch_loss / batch_count as f64;
// Validation loop
let mut val_loss = 0.0;
let mut val_count = 0;
for (static_feat, hist_feat, fut_feat, targets) in val_set.iter() {
let static_data: Vec<f32> = static_feat.iter().map(|&x| x as f32).collect();
let static_tensor = StreamTensor::from_vec(static_data.clone(), &[1, 10], &stream)?;
let hist_data: Vec<f32> = hist_feat.iter().map(|&x| x as f32).collect();
let hist_tensor = StreamTensor::from_vec(hist_data.clone(), &[1, 60, 50], &stream)?;
let fut_data: Vec<f32> = fut_feat.iter().map(|&x| x as f32).collect();
let fut_tensor = StreamTensor::from_vec(fut_data.clone(), &[1, 5, 10], &stream)?;
let target_data: Vec<f32> = targets.iter().map(|&x| x as f32).collect();
let target_tensor = StreamTensor::from_vec(target_data.clone(), &[1, 5], &stream)?;
let predictions = model.forward(&static_tensor, &hist_tensor, &fut_tensor)?;
let loss = model.compute_quantile_loss(&predictions, &target_tensor)?;
val_loss += loss as f64;
val_count += 1;
}
let avg_val_loss = val_loss / val_count as f64;
loss_history.push(avg_train_loss);
info!(
epoch = epoch + 1,
total_epochs = epochs,
train_loss = avg_train_loss,
val_loss = avg_val_loss,
"Epoch metrics"
);
}
// Step 5: Validate loss convergence
info!("Step 5: Validating training metrics...");
// Check losses are finite
for (i, &loss) in loss_history.iter().enumerate() {
assert!(
loss.is_finite(),
"Loss at epoch {} is not finite: {}",
i,
loss
);
assert!(loss >= 0.0, "Loss at epoch {} is negative: {}", i, loss);
}
let initial_loss = loss_history[0];
let final_loss = loss_history[loss_history.len() - 1];
let reduction = (initial_loss - final_loss) / initial_loss;
info!(
initial_loss,
final_loss,
reduction_pct = reduction * 100.0,
"Loss convergence"
);
// Note: Without actual gradient updates, we can only validate numerical stability
info!("Loss stability validated (forward pass only)");
// Step 6: Test inference with predictions
info!("Step 6: Testing inference...");
let (static_feat, hist_feat, fut_feat, _targets) = &tft_data[0];
let static_data: Vec<f32> = static_feat.iter().map(|&x| x as f32).collect();
let static_tensor = StreamTensor::from_vec(static_data.clone(), &[1, 10], &stream)?;
let hist_data: Vec<f32> = hist_feat.iter().map(|&x| x as f32).collect();
let hist_tensor = StreamTensor::from_vec(hist_data.clone(), &[1, 60, 50], &stream)?;
let fut_data: Vec<f32> = fut_feat.iter().map(|&x| x as f32).collect();
let fut_tensor = StreamTensor::from_vec(fut_data.clone(), &[1, 5, 10], &stream)?;
let prediction = model.forward(&static_tensor, &hist_tensor, &fut_tensor)?;
info!(prediction_shape = ?prediction.shape, "Prediction shape");
assert_eq!(
prediction.shape.as_slice(),
&[1, 5, 9],
"Prediction shape should be [batch=1, horizon=5, quantiles=9]"
);
// Extract quantile predictions (download to host for validation)
let pred_flat = prediction.to_vec()?;
// pred_flat is [1, 5, 9] row-major = 45 elements
for h in 0..5 {
let base = h * 9;
let median = pred_flat[base + 4]; // Middle quantile
let q10 = pred_flat[base];
let q90 = pred_flat[base + 8];
info!(
horizon = h + 1,
median,
q10,
q90,
"Quantile predictions"
);
// Validate quantile ordering
for i in 1..9 {
assert!(
pred_flat[base + i] >= pred_flat[base + i - 1],
"Quantiles must be monotonic: {} >= {}",
pred_flat[base + i],
pred_flat[base + i - 1]
);
}
}
info!("TFT training with real DBN data PASSED");
Ok(())
}
#[tokio::test]
async fn test_tft_dbn_data_loading_only() -> Result<()> {
info!("Test: DBN Data Loading (ES.FUT)");
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let dbn_path =
workspace_root.join("test_data/real/databento/ml_training/ES.FUT_ohlcv-1m_2024-03-25.dbn");
if !dbn_path.exists() {
warn!("DBN file not found, skipping test");
return Ok(());
}
let bars = load_dbn_ohlcv_bars(dbn_path.to_str().unwrap()).await?;
info!(bar_count = bars.len(), "Loaded bars");
assert!(!bars.is_empty(), "Should load at least some bars");
// Validate first bar
let first_bar = &bars[0];
info!(
timestamp = %first_bar.timestamp,
close = first_bar.close,
volume = first_bar.volume,
"First bar"
);
assert!(first_bar.close > 0.0, "Close price should be positive");
assert!(first_bar.volume >= 0.0, "Volume should be non-negative");
info!("DBN data loading test PASSED");
Ok(())
}
#[tokio::test]
async fn test_tft_data_conversion() -> Result<()> {
info!("Test: TFT Data Conversion");
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let dbn_path =
workspace_root.join("test_data/real/databento/ml_training/ES.FUT_ohlcv-1m_2024-03-25.dbn");
if !dbn_path.exists() {
warn!("DBN file not found, skipping test");
return Ok(());
}
let bars = load_dbn_ohlcv_bars(dbn_path.to_str().unwrap()).await?;
let tft_data = convert_to_tft_data(&bars, 60, 5)?;
info!(sample_count = tft_data.len(), "Created TFT samples");
assert!(!tft_data.is_empty(), "Should create TFT samples");
let (static_feat, hist_feat, fut_feat, targets) = &tft_data[0];
info!(
static_shape = ?static_feat.shape(),
hist_shape = ?hist_feat.shape(),
fut_shape = ?fut_feat.shape(),
target_shape = ?targets.shape(),
"Feature shapes"
);
assert_eq!(static_feat.len(), 10);
assert_eq!(hist_feat.shape(), &[60, 50]);
assert_eq!(fut_feat.shape(), &[5, 10]);
assert_eq!(targets.len(), 5);
info!("TFT data conversion test PASSED");
Ok(())
}