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>
845 lines
29 KiB
Rust
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(())
|
|
}
|