Patterns applied: - Pattern 2: Float comparison (2x: utils.rs, var_edge_cases_tests.rs) - Pattern 7: Date/time construction (2x: production_streaming.rs, streaming.rs) - Pattern 1: Duration/time ops (2x: rate limiter, semaphore) - Pattern 4: Optional field access (1x: position_tracker.rs) Changes: - data/src/utils.rs: Float sort with NaN handling - data/src/providers/benzinga/production_streaming.rs: Rate limiter + semaphore + date/time - data/src/providers/benzinga/streaming.rs: Date/time construction - risk/src/position_tracker.rs: Emergency fallback counter - risk/tests/var_edge_cases_tests.rs: Test helper float sort Test impact: 0 failures (182/182 passing) Compilation: Clean (0 errors, 0 warnings) Time: 25 min (44% under budget)
600 lines
17 KiB
Rust
600 lines
17 KiB
Rust
//! Test Fixtures for Real DBN Market Data
|
|
//!
|
|
//! This module provides efficient, cached access to real DBN market data for tests.
|
|
//! All data is loaded once and cached in static memory, shared across test threads.
|
|
//!
|
|
//! # Performance
|
|
//!
|
|
//! - **First access**: ~5-10ms (DBN file I/O)
|
|
//! - **Subsequent access**: ~0.1μs (static memory read)
|
|
//! - **Thread-safe**: Uses `once_cell::sync::Lazy` for safe concurrent access
|
|
//!
|
|
//! # Supported Symbols
|
|
//!
|
|
//! - **ES.FUT**: E-mini S&P 500 futures (2024-01-02, ~390 bars)
|
|
//! - **NQ.FUT**: E-mini NASDAQ-100 futures (2024-01-02, ~390 bars)
|
|
//! - **CL.FUT**: WTI Crude Oil futures (2024-01-02, ~1440 bars)
|
|
//!
|
|
//! # Usage Examples
|
|
//!
|
|
//! ```rust
|
|
//! use fixtures::{get_es_fut_bars, get_nq_fut_bars, get_regime_sample};
|
|
//!
|
|
//! #[tokio::test]
|
|
//! async fn test_strategy_with_real_data() {
|
|
//! // Fast: data cached after first call
|
|
//! let bars = get_es_fut_bars().await.unwrap();
|
|
//! assert!(!bars.is_empty());
|
|
//! }
|
|
//!
|
|
//! #[tokio::test]
|
|
//! async fn test_trending_regime() {
|
|
//! let bars = get_regime_sample(RegimeType::Trending).await.unwrap();
|
|
//! // bars filtered for trending market conditions
|
|
//! }
|
|
//! ```
|
|
|
|
use anyhow::Result;
|
|
use backtesting_service::dbn_data_source::DbnDataSource;
|
|
use backtesting_service::strategy_engine::MarketData;
|
|
use chrono::{DateTime, Utc};
|
|
use once_cell::sync::Lazy;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
|
|
// ============================================================================
|
|
// Static Cached Data (Singleton Pattern)
|
|
// ============================================================================
|
|
|
|
/// Cached ES.FUT bars (loaded once per test run)
|
|
static ES_FUT_CACHE: Lazy<Arc<RwLock<Option<Vec<MarketData>>>>> =
|
|
Lazy::new(|| Arc::new(RwLock::new(None)));
|
|
|
|
/// Cached NQ.FUT bars (loaded once per test run)
|
|
static NQ_FUT_CACHE: Lazy<Arc<RwLock<Option<Vec<MarketData>>>>> =
|
|
Lazy::new(|| Arc::new(RwLock::new(None)));
|
|
|
|
/// Cached CL.FUT bars (loaded once per test run)
|
|
static CL_FUT_CACHE: Lazy<Arc<RwLock<Option<Vec<MarketData>>>>> =
|
|
Lazy::new(|| Arc::new(RwLock::new(None)));
|
|
|
|
// ============================================================================
|
|
// Path Resolution
|
|
// ============================================================================
|
|
|
|
/// Get absolute path to project root
|
|
fn get_project_root() -> std::path::PathBuf {
|
|
let mut current = std::env::current_dir().expect("INVARIANT: Current directory should be accessible");
|
|
|
|
// Navigate up until we find the workspace root
|
|
while !current.join("Cargo.toml").exists() || !current.join("test_data").exists() {
|
|
if !current.pop() {
|
|
panic!("Could not find project root");
|
|
}
|
|
}
|
|
|
|
current
|
|
}
|
|
|
|
/// Get absolute path to a DBN test file
|
|
fn get_dbn_file_path(filename: &str) -> String {
|
|
get_project_root()
|
|
.join("test_data/real/databento")
|
|
.join(filename)
|
|
.to_string_lossy()
|
|
.to_string()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core Data Loading Functions
|
|
// ============================================================================
|
|
|
|
/// Get cached ES.FUT bars (E-mini S&P 500 futures)
|
|
///
|
|
/// # Data Details
|
|
///
|
|
/// - **Symbol**: ES.FUT
|
|
/// - **Date**: 2024-01-02
|
|
/// - **Timeframe**: 1-minute OHLCV
|
|
/// - **Bar count**: ~390 bars (typical trading day)
|
|
/// - **Price range**: ~4700-4750 (typical for 2024)
|
|
///
|
|
/// # Performance
|
|
///
|
|
/// - First call: ~5-10ms (DBN file load)
|
|
/// - Subsequent: ~0.1μs (cache hit)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// `Vec<MarketData>` with chronologically sorted bars
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// let bars = get_es_fut_bars().await?;
|
|
/// assert_eq!(bars[0].symbol, "ES.FUT");
|
|
/// assert!(bars.len() > 350);
|
|
/// ```
|
|
pub async fn get_es_fut_bars() -> Result<Vec<MarketData>> {
|
|
// Check cache first
|
|
{
|
|
let cache = ES_FUT_CACHE.read().await;
|
|
if let Some(ref bars) = *cache {
|
|
return Ok(bars.clone());
|
|
}
|
|
}
|
|
|
|
// Cache miss: load from DBN file
|
|
let mut file_mapping = HashMap::new();
|
|
file_mapping.insert(
|
|
"ES.FUT".to_string(),
|
|
get_dbn_file_path("ES.FUT_ohlcv-1m_2024-01-02.dbn"),
|
|
);
|
|
|
|
let data_source = DbnDataSource::new(file_mapping).await?;
|
|
let bars = data_source.load_ohlcv_bars("ES.FUT").await?;
|
|
|
|
// Cache for future calls
|
|
{
|
|
let mut cache = ES_FUT_CACHE.write().await;
|
|
*cache = Some(bars.clone());
|
|
}
|
|
|
|
Ok(bars)
|
|
}
|
|
|
|
/// Get cached NQ.FUT bars (E-mini NASDAQ-100 futures)
|
|
///
|
|
/// # Data Details
|
|
///
|
|
/// - **Symbol**: NQ.FUT
|
|
/// - **Date**: 2024-01-02
|
|
/// - **Timeframe**: 1-minute OHLCV
|
|
/// - **Bar count**: ~390 bars
|
|
/// - **Price range**: ~16500-16700 (typical for 2024)
|
|
///
|
|
/// # Performance
|
|
///
|
|
/// - First call: ~5-10ms
|
|
/// - Subsequent: ~0.1μs
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// let bars = get_nq_fut_bars().await?;
|
|
/// assert_eq!(bars[0].symbol, "NQ.FUT");
|
|
/// ```
|
|
pub async fn get_nq_fut_bars() -> Result<Vec<MarketData>> {
|
|
// Check cache first
|
|
{
|
|
let cache = NQ_FUT_CACHE.read().await;
|
|
if let Some(ref bars) = *cache {
|
|
return Ok(bars.clone());
|
|
}
|
|
}
|
|
|
|
// Cache miss: load from DBN file
|
|
let mut file_mapping = HashMap::new();
|
|
file_mapping.insert(
|
|
"NQ.FUT".to_string(),
|
|
get_dbn_file_path("NQ.FUT_ohlcv-1m_2024-01-02.dbn"),
|
|
);
|
|
|
|
let data_source = DbnDataSource::new(file_mapping).await?;
|
|
let bars = data_source.load_ohlcv_bars("NQ.FUT").await?;
|
|
|
|
// Cache for future calls
|
|
{
|
|
let mut cache = NQ_FUT_CACHE.write().await;
|
|
*cache = Some(bars.clone());
|
|
}
|
|
|
|
Ok(bars)
|
|
}
|
|
|
|
/// Get cached CL.FUT bars (WTI Crude Oil futures)
|
|
///
|
|
/// # Data Details
|
|
///
|
|
/// - **Symbol**: CL.FUT
|
|
/// - **Date**: 2024-01-02
|
|
/// - **Timeframe**: 1-minute OHLCV
|
|
/// - **Bar count**: ~1440 bars (24-hour trading)
|
|
/// - **Price range**: ~71-73 USD/barrel (typical for 2024)
|
|
///
|
|
/// # Performance
|
|
///
|
|
/// - First call: ~10-15ms (larger file)
|
|
/// - Subsequent: ~0.2μs
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// let bars = get_cl_fut_bars().await?;
|
|
/// assert!(bars.len() > 1400);
|
|
/// ```
|
|
pub async fn get_cl_fut_bars() -> Result<Vec<MarketData>> {
|
|
// Check cache first
|
|
{
|
|
let cache = CL_FUT_CACHE.read().await;
|
|
if let Some(ref bars) = *cache {
|
|
return Ok(bars.clone());
|
|
}
|
|
}
|
|
|
|
// Cache miss: load from DBN file
|
|
let mut file_mapping = HashMap::new();
|
|
file_mapping.insert(
|
|
"CL.FUT".to_string(),
|
|
get_dbn_file_path("CL.FUT_ohlcv-1m_2024-01-02.dbn"),
|
|
);
|
|
|
|
let data_source = DbnDataSource::new(file_mapping).await?;
|
|
let bars = data_source.load_ohlcv_bars("CL.FUT").await?;
|
|
|
|
// Cache for future calls
|
|
{
|
|
let mut cache = CL_FUT_CACHE.write().await;
|
|
*cache = Some(bars.clone());
|
|
}
|
|
|
|
Ok(bars)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Filtered Data Access
|
|
// ============================================================================
|
|
|
|
/// Get bars for a specific date and symbol
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `symbol` - Symbol name (e.g., "ES.FUT", "NQ.FUT", "CL.FUT")
|
|
/// * `date` - UTC date (only date component used, time ignored)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Bars matching the specified date, or empty vec if no data available
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use chrono::NaiveDate;
|
|
/// let date = NaiveDate::from_ymd_opt(2024, 1, 2).unwrap().and_hms_opt(0, 0, 0).expect("INVARIANT: Valid time parameters");
|
|
/// let bars = get_bars_for_date("ES.FUT", date).await?;
|
|
/// ```
|
|
pub async fn get_bars_for_date(symbol: &str, date: DateTime<Utc>) -> Result<Vec<MarketData>> {
|
|
let all_bars = match symbol {
|
|
"ES.FUT" => get_es_fut_bars().await?,
|
|
"NQ.FUT" => get_nq_fut_bars().await?,
|
|
"CL.FUT" => get_cl_fut_bars().await?,
|
|
_ => return Ok(Vec::new()),
|
|
};
|
|
|
|
// Filter by date (compare only the date component)
|
|
let target_date = date.date_naive();
|
|
let filtered: Vec<MarketData> = all_bars
|
|
.into_iter()
|
|
.filter(|bar| bar.timestamp.date_naive() == target_date)
|
|
.collect();
|
|
|
|
Ok(filtered)
|
|
}
|
|
|
|
/// Market regime types for filtered data access
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RegimeType {
|
|
/// Trending market (strong directional movement)
|
|
Trending,
|
|
/// Ranging/sideways market (bounded oscillation)
|
|
Ranging,
|
|
/// High volatility period
|
|
Volatile,
|
|
/// Low volatility period
|
|
Stable,
|
|
}
|
|
|
|
/// Get a sample of bars matching a specific market regime
|
|
///
|
|
/// This function analyzes cached data and returns a subset that exhibits
|
|
/// the requested market characteristics.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `regime_type` - The type of market regime to sample
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Vec of 50-100 bars exhibiting the requested regime characteristics
|
|
///
|
|
/// # Regime Detection Logic
|
|
///
|
|
/// - **Trending**: Price movement > 1.5% over 60 bars
|
|
/// - **Ranging**: Price range < 0.8% over 60 bars
|
|
/// - **Volatile**: Price standard deviation > 0.5%
|
|
/// - **Stable**: Price standard deviation < 0.3%
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// let trending_bars = get_regime_sample(RegimeType::Trending).await?;
|
|
/// // Use these bars to test trend-following strategies
|
|
/// ```
|
|
pub async fn get_regime_sample(regime_type: RegimeType) -> Result<Vec<MarketData>> {
|
|
// Use ES.FUT as default data source (most liquid)
|
|
let all_bars = get_es_fut_bars().await?;
|
|
|
|
if all_bars.len() < 100 {
|
|
return Ok(all_bars);
|
|
}
|
|
|
|
// Analyze bars to find regime match
|
|
let window_size = 60; // 1 hour window for regime detection
|
|
let mut best_match_idx = 0;
|
|
let mut best_score = 0.0;
|
|
|
|
for i in 0..(all_bars.len() - window_size) {
|
|
let window = &all_bars[i..i + window_size];
|
|
let score = calculate_regime_score(window, regime_type);
|
|
|
|
if score > best_score {
|
|
best_score = score;
|
|
best_match_idx = i;
|
|
}
|
|
}
|
|
|
|
// Return 100 bars starting from best match
|
|
let end_idx = (best_match_idx + 100).min(all_bars.len());
|
|
Ok(all_bars[best_match_idx..end_idx].to_vec())
|
|
}
|
|
|
|
/// Calculate how well a window matches a regime type
|
|
fn calculate_regime_score(window: &[MarketData], regime_type: RegimeType) -> f64 {
|
|
if window.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
let prices: Vec<f64> = window
|
|
.iter()
|
|
.map(|bar| bar.close.to_string().parse::<f64>().unwrap_or(0.0))
|
|
.collect();
|
|
|
|
let first_price = prices[0];
|
|
let last_price = *prices.last().expect("INVARIANT: Collection should be non-empty");
|
|
let mean = prices.iter().sum::<f64>() / prices.len() as f64;
|
|
|
|
// Calculate standard deviation
|
|
let variance: f64 =
|
|
prices.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / prices.len() as f64;
|
|
let std_dev = variance.sqrt();
|
|
let std_dev_pct = (std_dev / mean) * 100.0;
|
|
|
|
// Calculate price change percentage
|
|
let price_change_pct = ((last_price - first_price) / first_price).abs() * 100.0;
|
|
|
|
// Calculate price range
|
|
let max_price = prices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
|
let min_price = prices.iter().cloned().fold(f64::INFINITY, f64::min);
|
|
let range_pct = ((max_price - min_price) / mean) * 100.0;
|
|
|
|
match regime_type {
|
|
RegimeType::Trending => {
|
|
// High price change, moderate to high volatility
|
|
price_change_pct * 2.0 + std_dev_pct
|
|
},
|
|
RegimeType::Ranging => {
|
|
// Low price change, low to moderate range
|
|
let range_score = if range_pct < 1.0 {
|
|
100.0 - range_pct * 20.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let change_score = if price_change_pct < 0.5 { 50.0 } else { 0.0 };
|
|
range_score + change_score
|
|
},
|
|
RegimeType::Volatile => {
|
|
// High standard deviation
|
|
std_dev_pct * 10.0
|
|
},
|
|
RegimeType::Stable => {
|
|
// Low standard deviation
|
|
if std_dev_pct < 0.5 {
|
|
100.0 - std_dev_pct * 50.0
|
|
} else {
|
|
0.0
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Multi-Symbol Access
|
|
// ============================================================================
|
|
|
|
/// Get bars for multiple symbols
|
|
///
|
|
/// Loads data for multiple symbols in parallel for efficiency.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `symbols` - Slice of symbol names
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// HashMap mapping symbol name to bars
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// let symbols = vec!["ES.FUT", "NQ.FUT"];
|
|
/// let data = get_multi_symbol_bars(&symbols).await?;
|
|
/// assert!(data.contains_key("ES.FUT"));
|
|
/// ```
|
|
pub async fn get_multi_symbol_bars(symbols: &[&str]) -> Result<HashMap<String, Vec<MarketData>>> {
|
|
let mut result = HashMap::new();
|
|
|
|
// Load all symbols in parallel
|
|
let mut handles = vec![];
|
|
|
|
for &symbol in symbols {
|
|
let symbol = symbol.to_string();
|
|
handles.push(tokio::spawn(async move {
|
|
let bars = match symbol.as_str() {
|
|
"ES.FUT" => get_es_fut_bars().await,
|
|
"NQ.FUT" => get_nq_fut_bars().await,
|
|
"CL.FUT" => get_cl_fut_bars().await,
|
|
_ => Ok(Vec::new()),
|
|
};
|
|
(symbol, bars)
|
|
}));
|
|
}
|
|
|
|
// Collect results
|
|
for handle in handles {
|
|
let (symbol, bars) = handle.await?;
|
|
if let Ok(bars) = bars {
|
|
result.insert(symbol, bars);
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unit Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_es_fut_cache() -> Result<()> {
|
|
use std::time::Instant;
|
|
|
|
// First call (cold cache)
|
|
let start = Instant::now();
|
|
let bars1 = get_es_fut_bars().await?;
|
|
let cold_duration = start.elapsed();
|
|
|
|
assert!(!bars1.is_empty(), "Should load ES.FUT bars");
|
|
assert!(bars1.len() > 350 && bars1.len() < 450, "Expected ~390 bars");
|
|
|
|
// Second call (warm cache)
|
|
let start = Instant::now();
|
|
let bars2 = get_es_fut_bars().await?;
|
|
let warm_duration = start.elapsed();
|
|
|
|
assert_eq!(bars1.len(), bars2.len(), "Cache should return same data");
|
|
|
|
println!("Cache performance:");
|
|
println!(" Cold: {:?}", cold_duration);
|
|
println!(" Warm: {:?}", warm_duration);
|
|
|
|
// Warm should be significantly faster
|
|
assert!(
|
|
warm_duration < cold_duration / 10,
|
|
"Cached access should be >10x faster"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_nq_fut_cache() -> Result<()> {
|
|
let bars = get_nq_fut_bars().await?;
|
|
assert!(!bars.is_empty(), "Should load NQ.FUT bars");
|
|
assert_eq!(bars[0].symbol, "NQ.FUT");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cl_fut_cache() -> Result<()> {
|
|
let bars = get_cl_fut_bars().await?;
|
|
assert!(!bars.is_empty(), "Should load CL.FUT bars");
|
|
assert!(bars.len() > 1400, "CL.FUT has 24-hour trading");
|
|
assert_eq!(bars[0].symbol, "CL.FUT");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bars_for_date() -> Result<()> {
|
|
use chrono::NaiveDate;
|
|
|
|
let date = NaiveDate::from_ymd_opt(2024, 1, 2)
|
|
.unwrap()
|
|
.and_hms_opt(0, 0, 0)
|
|
.unwrap()
|
|
.and_utc();
|
|
|
|
let bars = get_bars_for_date("ES.FUT", date).await?;
|
|
assert!(!bars.is_empty(), "Should find bars for 2024-01-02");
|
|
|
|
// All bars should be from requested date
|
|
for bar in &bars {
|
|
assert_eq!(bar.timestamp.date_naive(), date.date_naive());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_regime_samples() -> Result<()> {
|
|
// Test trending regime
|
|
let trending = get_regime_sample(RegimeType::Trending).await?;
|
|
assert!(!trending.is_empty(), "Should find trending sample");
|
|
|
|
// Test ranging regime
|
|
let ranging = get_regime_sample(RegimeType::Ranging).await?;
|
|
assert!(!ranging.is_empty(), "Should find ranging sample");
|
|
|
|
// Test volatile regime
|
|
let volatile = get_regime_sample(RegimeType::Volatile).await?;
|
|
assert!(!volatile.is_empty(), "Should find volatile sample");
|
|
|
|
// Test stable regime
|
|
let stable = get_regime_sample(RegimeType::Stable).await?;
|
|
assert!(!stable.is_empty(), "Should find stable sample");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_multi_symbol_bars() -> Result<()> {
|
|
let symbols = vec!["ES.FUT", "NQ.FUT", "CL.FUT"];
|
|
let data = get_multi_symbol_bars(&symbols).await?;
|
|
|
|
assert_eq!(data.len(), 3, "Should load all 3 symbols");
|
|
assert!(data.contains_key("ES.FUT"));
|
|
assert!(data.contains_key("NQ.FUT"));
|
|
assert!(data.contains_key("CL.FUT"));
|
|
|
|
// Verify data quality
|
|
for (symbol, bars) in &data {
|
|
assert!(!bars.is_empty(), "Symbol {} should have bars", symbol);
|
|
assert_eq!(bars[0].symbol, *symbol);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_thread_safety() -> Result<()> {
|
|
// Spawn multiple concurrent reads
|
|
let mut handles = vec![];
|
|
|
|
for _ in 0..10 {
|
|
handles.push(tokio::spawn(async { get_es_fut_bars().await }));
|
|
}
|
|
|
|
// All should succeed
|
|
for handle in handles {
|
|
let bars = handle.await??;
|
|
assert!(!bars.is_empty());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|