//! DBN-based Repository Implementation //! //! This module provides MarketDataRepository implementation that loads data from DBN files //! instead of a database. This enables backtesting with real historical market data. use anyhow::{Context, Result}; use async_trait::async_trait; use chrono::{DateTime, Timelike}; use rust_decimal::prelude::ToPrimitive; use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info, warn}; use crate::dbn_data_source::DbnDataSource; use crate::repositories::MarketDataRepository; use crate::strategy_engine::MarketData; /// MarketDataRepository implementation using DBN files /// /// This repository loads historical market data from DBN (Databento Binary) files /// instead of a database, enabling backtesting with production-quality data. /// /// ## Features /// /// - Direct DBN file loading via DbnDataSource /// - Zero-copy parsing with SIMD optimizations /// - Support for multi-symbol backtests /// - Configurable file paths per symbol /// /// ## Usage /// /// ```rust,no_run /// use backtesting_service::dbn_repository::DbnMarketDataRepository; /// use std::collections::HashMap; /// /// # async fn example() -> anyhow::Result<()> { /// let mut file_mapping = HashMap::new(); /// file_mapping.insert("ES.FUT".to_string(), /// "test_data/real/databento/ES.FUT_ohlcv-1m_2024-01-02.dbn".to_string()); /// /// let repo = DbnMarketDataRepository::new(file_mapping).await?; /// /// // Use in backtesting /// let symbols = vec!["ES.FUT".to_string()]; /// let data = repo.load_historical_data(&symbols, start_time, end_time).await?; /// # Ok(()) /// # } /// ``` pub struct DbnMarketDataRepository { /// DBN data source data_source: Arc, /// Symbol mapping for test compatibility (e.g., BTC/USD -> ES.FUT) /// Maps requested symbol to actual data symbol symbol_mappings: HashMap, } impl DbnMarketDataRepository { /// Create new DBN-based market data repository /// /// # Arguments /// /// * `file_mapping` - Map of symbol to DBN file path /// /// # Returns /// /// Configured repository ready for backtesting pub async fn new(file_mapping: HashMap) -> Result { Self::new_with_mappings(file_mapping, HashMap::new()).await } /// Create new DBN-based market data repository with symbol mappings /// /// # Arguments /// /// * `file_mapping` - Map of symbol to DBN file path /// * `symbol_mappings` - Map of requested symbol to actual data symbol /// Example: {"BTC/USD" -> "ES.FUT", "ETH/USD" -> "ES.FUT"} /// /// # Returns /// /// Configured repository ready for backtesting with symbol mapping support pub async fn new_with_mappings( file_mapping: HashMap, symbol_mappings: HashMap, ) -> Result { let data_source = Arc::new( DbnDataSource::new(file_mapping) .await .context("Failed to create DBN data source")?, ); if !symbol_mappings.is_empty() { info!( "Created DBN market data repository with {} symbols and {} symbol mappings", data_source.available_symbols().len(), symbol_mappings.len() ); for (from, to) in &symbol_mappings { debug!(" Symbol mapping: {} -> {}", from, to); } } else { info!( "Created DBN market data repository with {} symbols", data_source.available_symbols().len() ); } Ok(Self { data_source, symbol_mappings, }) } /// Create repository with existing data source pub fn with_data_source(data_source: Arc) -> Self { Self { data_source, symbol_mappings: HashMap::new(), } } /// Get available symbols in this repository pub fn available_symbols(&self) -> Vec { self.data_source.available_symbols() } /// Load market data by precise time range /// /// More efficient than load_historical_data() when you need precise /// time filtering with DateTime objects. /// /// # Arguments /// /// * `symbols` - List of symbols to load /// * `start` - Start timestamp /// * `end` - End timestamp /// /// # Returns /// /// Vector of market data events sorted by timestamp /// /// # Performance /// /// Target: <10ms for typical test scenarios (~400 bars) pub async fn load_by_time_range( &self, symbols: &[String], start: DateTime, end: DateTime, ) -> Result> { debug!( "Loading DBN data by time range for {} symbols ({} to {})", symbols.len(), start, end ); // Convert DateTime to nanosecond timestamps let start_nanos = start .timestamp_nanos_opt() .ok_or_else(|| anyhow::anyhow!("Invalid start timestamp"))?; let end_nanos = end .timestamp_nanos_opt() .ok_or_else(|| anyhow::anyhow!("Invalid end timestamp"))?; // Reuse existing load_historical_data implementation self.load_historical_data(symbols, start_nanos, end_nanos) .await } /// Load market data with volume filtering /// /// Filters for high-liquidity bars to focus on tradeable periods. /// /// # Arguments /// /// * `symbols` - List of symbols to load /// * `min_volume` - Minimum volume threshold /// * `start_time` - Start timestamp in nanoseconds /// * `end_time` - End timestamp in nanoseconds /// /// # Returns /// /// Vector of market data events with volume >= min_volume pub async fn load_with_volume_filter( &self, symbols: &[String], min_volume: rust_decimal::Decimal, start_time: i64, end_time: i64, ) -> Result> { let all_data = self .load_historical_data(symbols, start_time, end_time) .await?; let filtered: Vec = all_data .into_iter() .filter(|bar| bar.volume >= min_volume) .collect(); info!( "Volume filter applied: {} bars with volume >= {}", filtered.len(), min_volume ); Ok(filtered) } /// Load regime-specific sample data /// /// Loads data samples that match specific market regime characteristics. /// Useful for testing regime detection and adaptive strategies. /// /// # Arguments /// /// * `regime_type` - Type of regime to sample ("trending", "ranging", "volatile", "stable") /// * `count` - Number of samples to return /// * `symbols` - Symbols to load from /// /// # Returns /// /// Vector of market data samples from the specified regime /// /// # Note /// /// This is a simplified implementation that uses heuristics. /// For production, integrate with regime detection models. pub async fn load_regime_samples( &self, regime_type: &str, count: usize, symbols: &[String], ) -> Result> { // Load all available data let all_bars = self.data_source.load_multi_symbol_bars(symbols).await?; // Simple heuristic-based filtering (can be enhanced with ML models) let mut samples: Vec = match regime_type.to_lowercase().as_str() { "trending" => { // High price movement, consistent direction all_bars .into_iter() .filter(|bar| { let range = bar.high - bar.low; let avg_price = (bar.high + bar.low) / rust_decimal::Decimal::new(2, 0); let range_pct = range / avg_price; range_pct > rust_decimal::Decimal::new(5, 3) // >0.5% range }) .collect() }, "ranging" | "sideways" => { // Low price movement, narrow range all_bars .into_iter() .filter(|bar| { let range = bar.high - bar.low; let avg_price = (bar.high + bar.low) / rust_decimal::Decimal::new(2, 0); let range_pct = range / avg_price; range_pct < rust_decimal::Decimal::new(2, 3) // <0.2% range }) .collect() }, "volatile" => { // High volume and wide ranges all_bars .into_iter() .filter(|bar| { let range = bar.high - bar.low; let avg_price = (bar.high + bar.low) / rust_decimal::Decimal::new(2, 0); let range_pct = range / avg_price; range_pct > rust_decimal::Decimal::new(8, 3) // >0.8% range && bar.volume > rust_decimal::Decimal::new(100, 0) }) .collect() }, "stable" => { // Low volatility, consistent prices all_bars .into_iter() .filter(|bar| { let range = bar.high - bar.low; let avg_price = (bar.high + bar.low) / rust_decimal::Decimal::new(2, 0); let range_pct = range / avg_price; range_pct < rust_decimal::Decimal::new(15, 4) // <0.15% range }) .collect() }, _ => { return Err(anyhow::anyhow!( "Unknown regime type: {}. Valid: trending, ranging, volatile, stable", regime_type )); }, }; // Limit to requested count samples.truncate(count); info!( "Loaded {} regime samples (regime: {}, requested: {})", samples.len(), regime_type, count ); Ok(samples) } /// Get date range for a specific symbol /// /// Returns the first and last available timestamps for the symbol. /// /// # Arguments /// /// * `symbol` - Symbol to query /// /// # Returns /// /// Tuple of (first_timestamp, last_timestamp) if data exists pub async fn get_date_range( &self, symbol: &str, ) -> Result<(DateTime, DateTime)> { // Load all bars for symbol let bars = self.data_source.load_ohlcv_bars(symbol).await?; if bars.is_empty() { return Err(anyhow::anyhow!("No data found for symbol: {}", symbol)); } // SAFETY: bars is non-empty (validated by is_empty check above) let first = bars .first() .ok_or_else(|| anyhow::anyhow!("INVARIANT violated: bars is empty after check"))? .timestamp; let last = bars .last() .ok_or_else(|| anyhow::anyhow!("INVARIANT violated: bars is empty after check"))? .timestamp; debug!("Date range for {}: {} to {}", symbol, first, last); Ok((first, last)) } /// Resample bars to a different timeframe /// /// Aggregates minute bars into larger timeframes (5m, 15m, 1h, etc.) /// /// # Arguments /// /// * `bars` - Input bars (typically 1-minute) /// * `target_minutes` - Target timeframe in minutes (5, 15, 60, etc.) /// /// # Returns /// /// Resampled bars at the target timeframe pub fn resample_bars( &self, bars: &[MarketData], target_minutes: u32, ) -> Result> { if bars.is_empty() { return Ok(Vec::new()); } let mut resampled = Vec::new(); let mut current_bucket: Option> = None; for bar in bars { let bucket_start = bar .timestamp .with_minute((bar.timestamp.minute() / target_minutes) * target_minutes) .ok_or_else(|| anyhow::anyhow!("Invalid minute in timestamp resampling"))? .with_second(0) .ok_or_else(|| anyhow::anyhow!("Invalid second in timestamp resampling"))? .with_nanosecond(0) .ok_or_else(|| anyhow::anyhow!("Invalid nanosecond in timestamp resampling"))?; // Start new bucket or add to existing match &mut current_bucket { None => { current_bucket = Some(vec![bar.clone()]); }, Some(bucket) => { #[allow(clippy::indexing_slicing)] // bucket guaranteed non-empty (created with 1 element on line 378) let first_bar = &bucket[0]; let first_bucket_start = first_bar .timestamp .with_minute( (first_bar.timestamp.minute() / target_minutes) * target_minutes, ) .ok_or_else(|| anyhow::anyhow!("Invalid minute in timestamp resampling"))? .with_second(0) .ok_or_else(|| anyhow::anyhow!("Invalid second in timestamp resampling"))? .with_nanosecond(0) .ok_or_else(|| anyhow::anyhow!("Invalid nanosecond in timestamp resampling"))?; if bucket_start == first_bucket_start { // Same bucket bucket.push(bar.clone()); } else { // New bucket - aggregate previous if let Some(aggregated) = Self::aggregate_bucket(bucket)? { resampled.push(aggregated); } current_bucket = Some(vec![bar.clone()]); } }, } } // Aggregate final bucket if let Some(bucket) = current_bucket { if let Some(aggregated) = Self::aggregate_bucket(&bucket)? { resampled.push(aggregated); } } info!( "Resampled {} bars to {} bars ({}m timeframe)", bars.len(), resampled.len(), target_minutes ); Ok(resampled) } /// Aggregate a bucket of bars into a single bar fn aggregate_bucket(bucket: &[MarketData]) -> Result> { if bucket.is_empty() { return Ok(None); } #[allow(clippy::indexing_slicing)] // Bounds checked above: !is_empty() let first = &bucket[0]; // SAFETY: bucket is non-empty (validated by is_empty check above) let last = bucket .last() .ok_or_else(|| anyhow::anyhow!("INVARIANT violated: bucket is empty after check"))?; // Calculate OHLCV let open = first.open; let close = last.close; let high = bucket.iter().map(|b| b.high).max().unwrap_or(first.high); let low = bucket.iter().map(|b| b.low).min().unwrap_or(first.low); let volume: rust_decimal::Decimal = bucket.iter().map(|b| b.volume).sum(); Ok(Some(MarketData { symbol: first.symbol.clone(), timestamp: first.timestamp, open, high, low, close, volume, })) } /// Calculate rolling statistics over a window /// /// # Arguments /// /// * `bars` - Input bars /// * `window_size` - Number of bars in rolling window /// /// # Returns /// /// Vector of (mean, std_dev, min, max) for each window pub fn calculate_rolling_stats( &self, bars: &[MarketData], window_size: usize, ) -> Vec<(f64, f64, f64, f64)> { if bars.len() < window_size { return Vec::new(); } let mut stats = Vec::new(); for i in 0..=(bars.len() - window_size) { let window = &bars[i..i + window_size]; // Extract close prices let closes: Vec = window.iter().filter_map(|b| b.close.to_f64()).collect(); if closes.is_empty() { continue; } // Calculate statistics let mean = closes.iter().sum::() / closes.len() as f64; let variance = closes.iter().map(|x| (x - mean).powi(2)).sum::() / closes.len() as f64; let std_dev = variance.sqrt(); let min = closes.iter().cloned().fold(f64::INFINITY, f64::min); let max = closes.iter().cloned().fold(f64::NEG_INFINITY, f64::max); stats.push((mean, std_dev, min, max)); } stats } /// Generate summary statistics for loaded data /// /// # Arguments /// /// * `bars` - Input bars /// /// # Returns /// /// HashMap of statistic name to value pub fn generate_summary_stats(&self, bars: &[MarketData]) -> HashMap { let mut stats = HashMap::new(); if bars.is_empty() { return stats; } // Basic counts stats.insert("count".to_string(), bars.len() as f64); // Price statistics let closes: Vec = bars.iter().filter_map(|b| b.close.to_f64()).collect(); if !closes.is_empty() { let mean = closes.iter().sum::() / closes.len() as f64; let variance = closes.iter().map(|x| (x - mean).powi(2)).sum::() / closes.len() as f64; let std_dev = variance.sqrt(); stats.insert("mean_close".to_string(), mean); stats.insert("std_close".to_string(), std_dev); stats.insert( "min_close".to_string(), closes.iter().cloned().fold(f64::INFINITY, f64::min), ); stats.insert( "max_close".to_string(), closes.iter().cloned().fold(f64::NEG_INFINITY, f64::max), ); } // Volume statistics let volumes: Vec = bars.iter().filter_map(|b| b.volume.to_f64()).collect(); if !volumes.is_empty() { let mean_vol = volumes.iter().sum::() / volumes.len() as f64; stats.insert("mean_volume".to_string(), mean_vol); stats.insert("total_volume".to_string(), volumes.iter().sum::()); } stats } } #[async_trait] impl MarketDataRepository for DbnMarketDataRepository { /// Load historical market data from DBN files /// /// Loads data for all requested symbols and filters by time range. /// Applies symbol mapping if configured (e.g., BTC/USD -> ES.FUT). /// /// # Arguments /// /// * `symbols` - List of symbols to load /// * `start_time` - Start timestamp in nanoseconds since Unix epoch /// * `end_time` - End timestamp in nanoseconds since Unix epoch /// /// # Returns /// /// Vector of market data events sorted by timestamp async fn load_historical_data( &self, symbols: &[String], start_time: i64, end_time: i64, ) -> Result> { debug!( "Loading DBN data for {} symbols (range: {} to {})", symbols.len(), start_time, end_time ); // Apply symbol mappings (e.g., BTC/USD -> ES.FUT for test compatibility) let mut mapped_symbols = Vec::new(); let mut reverse_mapping: HashMap> = HashMap::new(); for symbol in symbols { if let Some(mapped) = self.symbol_mappings.get(symbol) { debug!(" Applying symbol mapping: {} -> {}", symbol, mapped); mapped_symbols.push(mapped.clone()); reverse_mapping .entry(mapped.clone()) .or_default() .push(symbol.clone()); } else { mapped_symbols.push(symbol.clone()); } } // Remove duplicates from mapped symbols mapped_symbols.sort(); mapped_symbols.dedup(); // Convert nanosecond timestamps to DateTime let start_dt = DateTime::from_timestamp(start_time / 1_000_000_000, 0) .ok_or_else(|| anyhow::anyhow!("Invalid start timestamp"))?; let end_dt = DateTime::from_timestamp(end_time / 1_000_000_000, 0) .ok_or_else(|| anyhow::anyhow!("Invalid end timestamp"))?; // Load data for mapped symbols let all_data = self .data_source .load_multi_symbol_bars(&mapped_symbols) .await?; // Filter by time range and restore original symbol names let mut filtered: Vec = all_data .into_iter() .filter(|bar| bar.timestamp >= start_dt && bar.timestamp <= end_dt) .flat_map(|bar| { // If this data symbol was mapped from multiple request symbols, // create separate bars for each (for multi-symbol backtests) if let Some(original_symbols) = reverse_mapping.get(&bar.symbol) { original_symbols .iter() .map(|orig_sym| { let mut bar_copy = bar.clone(); bar_copy.symbol = orig_sym.clone(); bar_copy }) .collect() } else { vec![bar] } }) .collect(); // Sort by timestamp across all symbols filtered.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); info!( "Loaded {} bars from DBN files (symbols: {:?}, mapped: {:?}, range: {} to {})", filtered.len(), symbols, mapped_symbols, start_dt, end_dt ); if !self.symbol_mappings.is_empty() { warn!( "Symbol mapping active: Using {} data for {} requested symbols", mapped_symbols.join(", "), symbols.len() ); } Ok(filtered) } } #[cfg(test)] #[allow(clippy::inconsistent_digit_grouping)] mod tests { use super::*; use chrono::{Datelike, TimeZone, Utc}; use rust_decimal::Decimal; fn get_test_file_path() -> String { // Get absolute path to test file (workspace root + relative path) let current_dir = std::env::current_dir().expect("INVARIANT: Current directory should be accessible"); let workspace_root = current_dir .ancestors() .find(|p| p.join("Cargo.toml").exists() && p.join("test_data").exists()) .expect("Could not find workspace root"); workspace_root .join("test_data/real/databento/ES.FUT_ohlcv-1m_2024-01-02.dbn") .to_string_lossy() .to_string() } #[tokio::test] async fn test_dbn_repository_creation() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await; assert!(repo.is_ok()); let repository = repo.unwrap(); assert_eq!(repository.available_symbols().len(), 1); } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_load_by_time_range() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let start = Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(); let end = Utc.with_ymd_and_hms(2024, 1, 2, 1, 0, 0).unwrap(); let symbols = vec!["ES.FUT".to_string()]; let result = repo.load_by_time_range(&symbols, start, end).await; assert!(result.is_ok()); let bars = result.unwrap(); assert!(!bars.is_empty(), "Should load bars in time range"); // Verify all bars are within range for bar in &bars { assert!(bar.timestamp >= start && bar.timestamp <= end); } } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_load_with_volume_filter() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let start_time = 1704153600_000_000_000i64; let end_time = 1704240000_000_000_000i64; let min_volume = Decimal::new(50, 0); let symbols = vec!["ES.FUT".to_string()]; let result = repo .load_with_volume_filter(&symbols, min_volume, start_time, end_time) .await; assert!(result.is_ok()); let bars = result.unwrap(); // Verify all bars meet volume threshold for bar in &bars { assert!(bar.volume >= min_volume, "Bar volume below threshold"); } } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_load_regime_samples_trending() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let symbols = vec!["ES.FUT".to_string()]; let result = repo.load_regime_samples("trending", 10, &symbols).await; assert!(result.is_ok()); let samples = result.unwrap(); // Should have up to 10 samples assert!(samples.len() <= 10); // Verify samples match trending characteristics (>0.5% range) for sample in &samples { let range = sample.high - sample.low; let avg_price = (sample.high + sample.low) / Decimal::new(2, 0); let range_pct = range / avg_price; assert!(range_pct > Decimal::new(5, 3), "Sample not trending"); } } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_load_regime_samples_ranging() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let symbols = vec!["ES.FUT".to_string()]; let result = repo.load_regime_samples("ranging", 10, &symbols).await; assert!(result.is_ok()); let samples = result.unwrap(); // Verify samples match ranging characteristics (<0.2% range) for sample in &samples { let range = sample.high - sample.low; let avg_price = (sample.high + sample.low) / Decimal::new(2, 0); let range_pct = range / avg_price; assert!(range_pct < Decimal::new(2, 3), "Sample not ranging"); } } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_load_regime_samples_invalid() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let symbols = vec!["ES.FUT".to_string()]; let result = repo .load_regime_samples("invalid_regime", 10, &symbols) .await; assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Unknown regime type")); } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_get_date_range() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let result = repo.get_date_range("ES.FUT").await; assert!(result.is_ok()); let (first, last) = result.unwrap(); assert!(first < last, "First timestamp should be before last"); // Should be within 2024-01-02 assert_eq!(first.year(), 2024); assert_eq!(first.month(), 1); assert_eq!(first.day(), 2); } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_resample_bars() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); // Load 1-minute bars let start_time = 1704153600_000_000_000i64; let end_time = 1704157200_000_000_000i64; // 1 hour let symbols = vec!["ES.FUT".to_string()]; let bars = repo .load_historical_data(&symbols, start_time, end_time) .await .unwrap(); // Resample to 5-minute bars let result = repo.resample_bars(&bars, 5); assert!(result.is_ok()); let resampled = result.unwrap(); // Should have fewer bars (5x fewer for 5-minute resampling) assert!( resampled.len() < bars.len(), "Resampled should have fewer bars" ); assert!(resampled.len() >= bars.len() / 6, "Too few resampled bars"); // Verify OHLC relationships for bar in &resampled { assert!(bar.low <= bar.open, "Low should be <= open"); assert!(bar.low <= bar.close, "Low should be <= close"); assert!(bar.high >= bar.open, "High should be >= open"); assert!(bar.high >= bar.close, "High should be >= close"); } } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_calculate_rolling_stats() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let start_time = 1704153600_000_000_000i64; let end_time = 1704157200_000_000_000i64; let symbols = vec!["ES.FUT".to_string()]; let bars = repo .load_historical_data(&symbols, start_time, end_time) .await .unwrap(); let window_size = 20; let stats = repo.calculate_rolling_stats(&bars, window_size); // Should have stats for each window assert_eq!(stats.len(), bars.len() - window_size + 1); // Verify stats structure for (mean, std_dev, min, max) in &stats { assert!(!mean.is_nan(), "Mean should be valid"); assert!(!std_dev.is_nan(), "StdDev should be valid"); assert!(min <= max, "Min should be <= max"); } } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_generate_summary_stats() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let start_time = 1704153600_000_000_000i64; let end_time = 1704157200_000_000_000i64; let symbols = vec!["ES.FUT".to_string()]; let bars = repo .load_historical_data(&symbols, start_time, end_time) .await .unwrap(); let stats = repo.generate_summary_stats(&bars); // Should have all expected statistics assert!(stats.contains_key("count")); assert!(stats.contains_key("mean_close")); assert!(stats.contains_key("std_close")); assert!(stats.contains_key("min_close")); assert!(stats.contains_key("max_close")); assert!(stats.contains_key("mean_volume")); assert!(stats.contains_key("total_volume")); // Verify basic sanity assert_eq!(stats.get("count").expect("INVARIANT: Key should exist in map"), &(bars.len() as f64)); assert!(stats.get("mean_close").expect("INVARIANT: Key should exist in map") > &0.0); assert!(stats.get("std_close").expect("INVARIANT: Key should exist in map") >= &0.0); } #[tokio::test] async fn test_empty_bars_edge_cases() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); // Test empty resampling let empty_bars: Vec = Vec::new(); let result = repo.resample_bars(&empty_bars, 5); assert!(result.is_ok()); assert!(result.unwrap().is_empty()); // Test empty stats let stats = repo.generate_summary_stats(&empty_bars); assert!(stats.is_empty()); // Test rolling stats with insufficient data let stats = repo.calculate_rolling_stats(&empty_bars, 20); assert!(stats.is_empty()); } #[tokio::test] #[ignore] // Requires local DBN test data (test_data/real/databento/) async fn test_performance_target() { let mut file_mapping = HashMap::new(); file_mapping.insert("ES.FUT".to_string(), get_test_file_path()); let repo = DbnMarketDataRepository::new(file_mapping).await.unwrap(); let start = std::time::Instant::now(); let start_time = 1704153600_000_000_000i64; let end_time = 1704157200_000_000_000i64; let symbols = vec!["ES.FUT".to_string()]; let bars = repo .load_historical_data(&symbols, start_time, end_time) .await .unwrap(); let duration = start.elapsed(); // Should meet <10ms performance target println!( "Loaded {} bars in {:?} ({:.2}ms)", bars.len(), duration, duration.as_secs_f64() * 1000.0 ); // Note: This is a soft target - may vary by system if bars.len() < 500 { assert!( duration.as_millis() < 50, "Performance target missed: {}ms for {} bars", duration.as_millis(), bars.len() ); } } }