Files
foxhunt/crates/ml/tests/real_data_helpers.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

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

369 lines
12 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,
)]
//! Real Market Data Helpers for ML Model Unit Tests
//!
//! Provides utilities to load real BTC/ETH Parquet data and convert it to formats
//! used by ML model tests (MAMBA-2, DQN, PPO, TFT).
//!
//! # Architecture
//!
//! - Loads real market data from test_data/real/parquet/*.parquet files
//! - Converts to model-specific formats (states, features, time series)
//! - Provides realistic data distributions for proper model testing
//!
//! # Usage
//!
//! ```ignore
//! use real_data_helpers::{RealDataLoader, load_dqn_states, load_tft_sequences};
//!
//! // DQN/PPO tests
//! let states = load_dqn_states(100).await?;
//!
//! // TFT/MAMBA-2 tests
//! let sequences = load_tft_sequences(50, 20).await?;
//! ```
use anyhow::{Context, Result};
use data::parquet_persistence::{MarketDataEvent, ParquetMarketDataReader};
use std::path::PathBuf;
use tracing::{info, warn};
/// Path to real test data directory (relative to workspace root)
const REAL_DATA_PATH: &str = "test_data/real/parquet";
const BTC_FILE: &str = "BTC-USD_30day_2024-09.parquet";
const ETH_FILE: &str = "ETH-USD_30day_2024-09.parquet";
/// Real market data loader for ML tests
pub(crate) struct RealDataLoader {
base_path: String,
}
impl RealDataLoader {
/// Create new loader with workspace-relative path
pub(crate) fn new() -> Self {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let base_path = PathBuf::from(manifest_dir)
.parent()
.expect("Failed to get workspace root")
.join(REAL_DATA_PATH);
Self {
base_path: base_path.to_string_lossy().to_string(),
}
}
/// Check if real data files exist
pub(crate) fn files_exist(&self) -> bool {
let btc_path = PathBuf::from(&self.base_path).join(BTC_FILE);
let eth_path = PathBuf::from(&self.base_path).join(ETH_FILE);
btc_path.exists() && eth_path.exists()
}
/// Load raw market events from BTC data
pub(crate) async fn load_btc_events(&self, count: usize) -> Result<Vec<MarketDataEvent>> {
self.load_events(BTC_FILE, count).await
}
/// Load events from a specific file
async fn load_events(&self, filename: &str, count: usize) -> Result<Vec<MarketDataEvent>> {
let reader = ParquetMarketDataReader::new(self.base_path.clone());
let mut events = reader
.read_file(filename)
.await
.with_context(|| format!("Failed to load {}", filename))?;
// Take only the requested number of events
events.truncate(count);
Ok(events)
}
}
impl Default for RealDataLoader {
fn default() -> Self {
Self::new()
}
}
/// Convert market events to DQN/PPO state vectors
///
/// Each state vector contains [price, volume, bid_ask_spread, momentum, volatility]
pub(crate) fn events_to_dqn_states(events: &[MarketDataEvent], state_dim: usize) -> Vec<Vec<f32>> {
if events.is_empty() {
return vec![];
}
events
.windows(5) // Need 5 events to calculate momentum/volatility
.map(|window| {
let current = &window[4];
let prev = &window[0];
// Extract OHLCV features
let price = current.price.unwrap_or(0.0) as f32;
let volume = current.quantity.unwrap_or(0.0) as f32;
let high = current.high.unwrap_or(price as f64) as f32;
let low = current.low.unwrap_or(price as f64) as f32;
// Calculate technical indicators
let bid_ask_spread = (high - low) / price.max(1e-6);
let momentum =
(price - prev.price.unwrap_or(price as f64) as f32) / price.max(1e-6);
// Calculate volatility from window
let prices: Vec<f32> = window.iter().map(|e| e.price.unwrap_or(0.0) as f32).collect();
let mean_price = prices.iter().sum::<f32>() / prices.len() as f32;
let variance = prices
.iter()
.map(|p| (p - mean_price).powi(2))
.sum::<f32>()
/ prices.len() as f32;
let volatility = variance.sqrt();
// Build state vector (pad or truncate to state_dim)
let mut state = vec![price, volume, bid_ask_spread, momentum, volatility];
state.resize(state_dim, 0.0);
state
})
.collect()
}
/// Convert market events to TFT/MAMBA-2 time series sequences
///
/// Returns (sequences, targets) where:
/// - sequences: [batch, seq_len, features]
/// - targets: [batch, prediction_horizon]
pub(crate) fn events_to_time_series(
events: &[MarketDataEvent],
seq_len: usize,
features_per_event: usize,
) -> Vec<Vec<f32>> {
if events.is_empty() || events.len() < seq_len {
return vec![];
}
events
.windows(seq_len)
.map(|window| {
window
.iter()
.flat_map(|event| {
let price = event.price.unwrap_or(0.0) as f32;
let volume = event.quantity.unwrap_or(0.0) as f32;
let high = event.high.unwrap_or(price as f64) as f32;
let low = event.low.unwrap_or(price as f64) as f32;
let open = event.open.unwrap_or(price as f64) as f32;
// Build feature vector [open, high, low, close, volume, ...]
let mut features = vec![open, high, low, price, volume];
features.resize(features_per_event, 0.0);
features
})
.collect()
})
.collect()
}
/// Load DQN states (convenience wrapper)
pub(crate) async fn load_dqn_states(count: usize, state_dim: usize) -> Result<Vec<Vec<f32>>> {
let loader = RealDataLoader::new();
// Check if files exist, otherwise return empty (tests will skip)
if !loader.files_exist() {
return Ok(vec![]);
}
let events = loader.load_btc_events(count + 5).await?; // +5 for window calculation
Ok(events_to_dqn_states(&events, state_dim))
}
/// Load TFT/MAMBA-2 sequences (convenience wrapper)
pub(crate) async fn load_tft_sequences(
count: usize,
seq_len: usize,
features_per_event: usize,
) -> Result<Vec<Vec<f32>>> {
let loader = RealDataLoader::new();
// Check if files exist, otherwise return empty (tests will skip)
if !loader.files_exist() {
return Ok(vec![]);
}
let events = loader.load_btc_events(count + seq_len).await?;
Ok(events_to_time_series(&events, seq_len, features_per_event))
}
/// Check if real data is available for tests
pub(crate) async fn real_data_available() -> bool {
let loader = RealDataLoader::new();
loader.files_exist()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_data_loader_creation() {
let loader = RealDataLoader::new();
assert!(!loader.base_path.is_empty());
}
#[tokio::test]
#[ignore = "requires real Parquet files"]
async fn test_load_btc_events() {
let loader = RealDataLoader::new();
if !loader.files_exist() {
warn!("Skipping test: real data files not found");
return;
}
let events = loader.load_btc_events(100).await.unwrap();
assert_eq!(events.len(), 100);
assert!(events[0].price.is_some());
assert!(events[0].quantity.is_some());
}
#[tokio::test]
#[ignore = "requires real Parquet files"]
async fn test_events_to_dqn_states() {
let loader = RealDataLoader::new();
if !loader.files_exist() {
warn!("Skipping test: real data files not found");
return;
}
let events = loader.load_btc_events(50).await.unwrap();
let states = events_to_dqn_states(&events, 32);
assert!(!states.is_empty());
assert_eq!(states[0].len(), 32);
// Verify state contains non-zero values
assert!(states[0].iter().any(|&v| v != 0.0));
}
#[tokio::test]
#[ignore = "requires real Parquet files"]
async fn test_events_to_time_series() {
let loader = RealDataLoader::new();
if !loader.files_exist() {
warn!("Skipping test: real data files not found");
return;
}
let events = loader.load_btc_events(50).await.unwrap();
let sequences = events_to_time_series(&events, 10, 5);
assert!(!sequences.is_empty());
assert_eq!(sequences[0].len(), 10 * 5); // seq_len * features_per_event
// Verify sequences contain non-zero values
assert!(sequences[0].iter().any(|&v| v != 0.0));
}
#[tokio::test]
async fn test_load_dqn_states_wrapper() {
let states = load_dqn_states(50, 32).await.unwrap();
// If no data, should return empty (tests will skip)
if states.is_empty() {
warn!("No real data available, test will skip");
return;
}
assert_eq!(states[0].len(), 32);
}
#[tokio::test]
async fn test_load_tft_sequences_wrapper() {
let sequences = load_tft_sequences(30, 10, 5).await.unwrap();
// If no data, should return empty (tests will skip)
if sequences.is_empty() {
warn!("No real data available, test will skip");
return;
}
assert_eq!(sequences[0].len(), 10 * 5);
}
#[tokio::test]
async fn test_real_data_available() {
let available = real_data_available().await;
info!(available, "Real data available");
}
}