Files
foxhunt/services/backtesting_service/tests/fixtures/mod.rs
jgrusewski 52630a77d3 perf: eliminate heap-alloc Decimal→float casts across 19 files (36 instances)
Replace all `.to_string().parse::<f32/f64>()` patterns with
`num_traits::ToPrimitive` methods (`.to_f32()`, `.to_f64()`).
Each string roundtrip heap-allocated per conversion — fatal in
DQN hot loop (300K+ bars × epochs). Decimal stays as canonical
financial type; conversions happen at GPU/float boundaries only.

Also fixes blocking_read() in async context (risk_integration.rs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:29:04 +01:00

601 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 num_traits::ToPrimitive;
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_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(())
}
}