#![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> { self.load_events(BTC_FILE, count).await } /// Load events from a specific file async fn load_events(&self, filename: &str, count: usize) -> Result> { 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> { 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 = window.iter().map(|e| e.price.unwrap_or(0.0) as f32).collect(); let mean_price = prices.iter().sum::() / prices.len() as f32; let variance = prices .iter() .map(|p| (p - mean_price).powi(2)) .sum::() / 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> { 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>> { 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>> { 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"); } }