**Summary**: Expanded real data coverage from 2 to 5 diverse symbols across equity, commodity, fixed income, and currency markets. All integration tests passing with zero data quality violations.
**Symbols Added**:
- GC (Gold Futures): 781 bars, 30 days, $0.00
- ZN.FUT (10-Year Treasury): 28,935 bars, 30 days, $0.11
- 6E.FUT (Euro FX): 29,937 bars, 30 days, $0.11
**Existing Symbols**:
- ES.FUT (S&P 500 E-mini): 1,674 bars, 1 day
- NQ.FUT (NASDAQ E-mini): 1,593 bars, 1 day
**Test Results**: 8/8 passing (100%)
- test_load_all_symbols
- test_multi_symbol_loading
- test_asset_class_price_ranges
- test_repository_multi_symbol
- test_data_availability_multi_symbol
- test_multi_symbol_quality
- test_cross_asset_correlation
- test_multi_symbol_performance
**Data Quality**: 62,920 bars validated, 0 OHLCV violations
**Performance**: <100ms for all symbols, 1,514 bars/ms throughput
**Production Ready**: 4/5 symbols (80%) - ES, NQ, ZN, 6E approved
**Budget Tracking**:
- Total spent: $0.62 of $125.00 (0.5%)
- Remaining: $124.38 (99.5%)
**Files Modified**:
- services/backtesting_service/tests/dbn_multi_symbol_tests.rs (+315 lines)
- services/backtesting_service/tests/mock_repositories.rs (+12 lines)
- MULTI_SYMBOL_INTEGRATION_COMPLETE.md (+415 lines)
- CLAUDE.md (updated with multi-symbol status)
**Next Steps**: Moving Average Crossover backtesting with multi-symbol data
🎯 Foxhunt Real Data Integration - Agent 24 Multi-Symbol Expansion
433 lines
13 KiB
Rust
433 lines
13 KiB
Rust
//! Mock repository implementations for backtesting service tests
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use rust_decimal::Decimal;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
|
|
use backtesting_service::foxhunt::tli::BacktestStatus;
|
|
use backtesting_service::performance::PerformanceMetrics;
|
|
use backtesting_service::repositories::*;
|
|
use backtesting_service::storage::BacktestSummary;
|
|
use backtesting_service::strategy_engine::{BacktestTrade, MarketData, NewsEvent, TimeFrame};
|
|
|
|
/// Mock market data repository for testing
|
|
pub struct MockMarketDataRepository {
|
|
pub data: Arc<RwLock<Vec<MarketData>>>,
|
|
}
|
|
|
|
impl MockMarketDataRepository {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
data: Arc::new(RwLock::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
pub fn with_data(data: Vec<MarketData>) -> Self {
|
|
Self {
|
|
data: Arc::new(RwLock::new(data)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MarketDataRepository for MockMarketDataRepository {
|
|
async fn load_historical_data(
|
|
&self,
|
|
symbols: &[String],
|
|
start_time: i64,
|
|
end_time: i64,
|
|
) -> Result<Vec<MarketData>> {
|
|
let data = self.data.read().await;
|
|
let filtered: Vec<MarketData> = data
|
|
.iter()
|
|
.filter(|d| {
|
|
symbols.contains(&d.symbol)
|
|
&& d.timestamp.timestamp_nanos_opt().unwrap_or(0) >= start_time
|
|
&& d.timestamp.timestamp_nanos_opt().unwrap_or(0) <= end_time
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
Ok(filtered)
|
|
}
|
|
|
|
async fn check_data_availability(
|
|
&self,
|
|
symbols: &[String],
|
|
_start_time: i64,
|
|
_end_time: i64,
|
|
) -> Result<HashMap<String, bool>> {
|
|
let mut availability = HashMap::new();
|
|
for symbol in symbols {
|
|
availability.insert(symbol.clone(), true);
|
|
}
|
|
Ok(availability)
|
|
}
|
|
}
|
|
|
|
/// Mock trading repository for testing
|
|
pub struct MockTradingRepository {
|
|
pub trades: Arc<RwLock<HashMap<String, Vec<BacktestTrade>>>>,
|
|
pub metrics: Arc<RwLock<HashMap<String, PerformanceMetrics>>>,
|
|
pub backtests: Arc<RwLock<Vec<BacktestSummary>>>,
|
|
}
|
|
|
|
impl MockTradingRepository {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
trades: Arc::new(RwLock::new(HashMap::new())),
|
|
metrics: Arc::new(RwLock::new(HashMap::new())),
|
|
backtests: Arc::new(RwLock::new(Vec::new())),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl TradingRepository for MockTradingRepository {
|
|
async fn save_backtest_results(
|
|
&self,
|
|
backtest_id: &str,
|
|
trades: &[BacktestTrade],
|
|
metrics: &PerformanceMetrics,
|
|
) -> Result<()> {
|
|
self.trades
|
|
.write()
|
|
.await
|
|
.insert(backtest_id.to_string(), trades.to_vec());
|
|
self.metrics
|
|
.write()
|
|
.await
|
|
.insert(backtest_id.to_string(), metrics.clone());
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_backtest_results(
|
|
&self,
|
|
backtest_id: &str,
|
|
) -> Result<(Vec<BacktestTrade>, PerformanceMetrics)> {
|
|
let trades = self
|
|
.trades
|
|
.read()
|
|
.await
|
|
.get(backtest_id)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let metrics = self
|
|
.metrics
|
|
.read()
|
|
.await
|
|
.get(backtest_id)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
Ok((trades, metrics))
|
|
}
|
|
|
|
async fn create_backtest_record(
|
|
&self,
|
|
backtest_id: &str,
|
|
strategy_name: &str,
|
|
symbols: &[String],
|
|
start_date: DateTime<Utc>,
|
|
end_date: DateTime<Utc>,
|
|
initial_capital: f64,
|
|
_parameters: &HashMap<String, String>,
|
|
description: &str,
|
|
) -> Result<()> {
|
|
let summary = BacktestSummary {
|
|
backtest_id: backtest_id.to_string(),
|
|
strategy_name: strategy_name.to_string(),
|
|
symbols: symbols.to_vec(),
|
|
status: BacktestStatus::Queued,
|
|
total_return: 0.0,
|
|
sharpe_ratio: 0.0,
|
|
max_drawdown: 0.0,
|
|
created_at: Utc::now(),
|
|
start_date,
|
|
end_date,
|
|
description: description.to_string(),
|
|
};
|
|
self.backtests.write().await.push(summary);
|
|
Ok(())
|
|
}
|
|
|
|
async fn update_backtest_status(
|
|
&self,
|
|
backtest_id: &str,
|
|
status: BacktestStatus,
|
|
_error_message: Option<&str>,
|
|
) -> Result<()> {
|
|
let mut backtests = self.backtests.write().await;
|
|
if let Some(bt) = backtests.iter_mut().find(|b| b.backtest_id == backtest_id) {
|
|
bt.status = status;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_backtests(
|
|
&self,
|
|
limit: u32,
|
|
offset: u32,
|
|
strategy_name: Option<String>,
|
|
status_filter: Option<BacktestStatus>,
|
|
) -> Result<Vec<BacktestSummary>> {
|
|
let backtests = self.backtests.read().await;
|
|
let filtered: Vec<BacktestSummary> = backtests
|
|
.iter()
|
|
.filter(|bt| {
|
|
let name_match = strategy_name
|
|
.as_ref()
|
|
.map(|n| bt.strategy_name == *n)
|
|
.unwrap_or(true);
|
|
let status_match = status_filter
|
|
.map(|s| bt.status == s)
|
|
.unwrap_or(true);
|
|
name_match && status_match
|
|
})
|
|
.skip(offset as usize)
|
|
.take(limit as usize)
|
|
.cloned()
|
|
.collect();
|
|
Ok(filtered)
|
|
}
|
|
|
|
async fn store_time_series_data(
|
|
&self,
|
|
_backtest_id: &str,
|
|
_timestamp: DateTime<Utc>,
|
|
_equity: f64,
|
|
_drawdown: f64,
|
|
) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Mock news repository for testing
|
|
pub struct MockNewsRepository {
|
|
pub events: Arc<RwLock<Vec<NewsEvent>>>,
|
|
}
|
|
|
|
impl MockNewsRepository {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
events: Arc::new(RwLock::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
pub fn with_events(events: Vec<NewsEvent>) -> Self {
|
|
Self {
|
|
events: Arc::new(RwLock::new(events)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl NewsRepository for MockNewsRepository {
|
|
async fn load_news_events(
|
|
&self,
|
|
symbols: &[String],
|
|
start_time: DateTime<Utc>,
|
|
end_time: DateTime<Utc>,
|
|
) -> Result<Vec<NewsEvent>> {
|
|
let events = self.events.read().await;
|
|
let filtered: Vec<NewsEvent> = events
|
|
.iter()
|
|
.filter(|e| {
|
|
e.symbols.iter().any(|s| symbols.contains(s))
|
|
&& e.timestamp >= start_time
|
|
&& e.timestamp <= end_time
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
Ok(filtered)
|
|
}
|
|
|
|
async fn get_sentiment_data(
|
|
&self,
|
|
symbols: &[String],
|
|
timestamp: DateTime<Utc>,
|
|
lookback_hours: i32,
|
|
) -> Result<HashMap<String, f64>> {
|
|
let events = self.events.read().await;
|
|
let lookback_time = timestamp - chrono::Duration::hours(lookback_hours as i64);
|
|
|
|
let mut sentiment_map = HashMap::new();
|
|
for symbol in symbols {
|
|
let sentiment: f64 = events
|
|
.iter()
|
|
.filter(|e| {
|
|
e.symbols.contains(symbol)
|
|
&& e.timestamp >= lookback_time
|
|
&& e.timestamp <= timestamp
|
|
})
|
|
.map(|e| e.sentiment)
|
|
.sum::<f64>()
|
|
/ events.len().max(1) as f64;
|
|
sentiment_map.insert(symbol.clone(), sentiment);
|
|
}
|
|
Ok(sentiment_map)
|
|
}
|
|
}
|
|
|
|
/// Mock combined repositories for testing
|
|
pub struct MockBacktestingRepositories {
|
|
market_data: Box<dyn MarketDataRepository>,
|
|
trading: Box<dyn TradingRepository>,
|
|
news: Box<dyn NewsRepository>,
|
|
}
|
|
|
|
impl MockBacktestingRepositories {
|
|
pub fn new(
|
|
market_data: Box<dyn MarketDataRepository>,
|
|
trading: Box<dyn TradingRepository>,
|
|
news: Box<dyn NewsRepository>,
|
|
) -> Self {
|
|
Self {
|
|
market_data,
|
|
trading,
|
|
news,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl BacktestingRepositories for MockBacktestingRepositories {
|
|
fn market_data(&self) -> &dyn MarketDataRepository {
|
|
self.market_data.as_ref()
|
|
}
|
|
|
|
fn trading(&self) -> &dyn TradingRepository {
|
|
self.trading.as_ref()
|
|
}
|
|
|
|
fn news(&self) -> &dyn NewsRepository {
|
|
self.news.as_ref()
|
|
}
|
|
}
|
|
|
|
/// Helper function to generate sample market data
|
|
///
|
|
/// Uses deterministic price pattern to avoid flaky tests
|
|
pub fn generate_sample_market_data(
|
|
symbol: &str,
|
|
num_points: usize,
|
|
start_price: f64,
|
|
volatility: f64,
|
|
) -> Vec<MarketData> {
|
|
let mut data = Vec::new();
|
|
let start_time = Utc::now() - chrono::Duration::days(num_points as i64);
|
|
|
|
for i in 0..num_points {
|
|
// Deterministic oscillation: price varies ±volatility in a sine wave pattern
|
|
// This ensures price crosses any reasonable trigger level multiple times
|
|
let phase = (i as f64) / (num_points as f64) * 4.0 * std::f64::consts::PI;
|
|
let price_multiplier = 1.0 + volatility * phase.sin();
|
|
let price = start_price * price_multiplier;
|
|
|
|
let timestamp = start_time + chrono::Duration::days(i as i64);
|
|
let open = Decimal::from_f64_retain(price * 0.99).unwrap_or(Decimal::ZERO);
|
|
let high = Decimal::from_f64_retain(price * 1.02).unwrap_or(Decimal::ZERO);
|
|
let low = Decimal::from_f64_retain(price * 0.98).unwrap_or(Decimal::ZERO);
|
|
let close = Decimal::from_f64_retain(price).unwrap_or(Decimal::ZERO);
|
|
// Deterministic volume based on index
|
|
let volume = Decimal::from_f64_retain(2000000.0 + (i as f64 * 1000.0))
|
|
.unwrap_or(Decimal::ZERO);
|
|
|
|
data.push(MarketData {
|
|
symbol: symbol.to_string(),
|
|
timestamp,
|
|
open,
|
|
high,
|
|
low,
|
|
close,
|
|
volume,
|
|
timeframe: TimeFrame::Daily,
|
|
});
|
|
}
|
|
|
|
data
|
|
}
|
|
|
|
/// Helper function to generate sample news events
|
|
pub fn generate_sample_news_events(
|
|
symbols: &[String],
|
|
num_events: usize,
|
|
) -> Vec<NewsEvent> {
|
|
use rand::Rng;
|
|
let mut rng = rand::thread_rng();
|
|
let mut events = Vec::new();
|
|
let start_time = Utc::now() - chrono::Duration::days(30);
|
|
|
|
for i in 0..num_events {
|
|
let timestamp = start_time + chrono::Duration::hours(i as i64 * 24 / num_events as i64);
|
|
let symbol_idx = rng.gen_range(0..symbols.len());
|
|
let sentiment = rng.gen_range(-1.0..1.0);
|
|
let importance = rng.gen_range(0.0..1.0);
|
|
|
|
events.push(NewsEvent {
|
|
id: format!("news_{}", i),
|
|
timestamp,
|
|
symbols: vec![symbols[symbol_idx].clone()],
|
|
title: format!("News event {} for {}", i, symbols[symbol_idx]),
|
|
content: format!("Sample news content {}", i),
|
|
sentiment,
|
|
importance,
|
|
source: "mock_source".to_string(),
|
|
});
|
|
}
|
|
|
|
events
|
|
}
|
|
|
|
/// Get project root directory
|
|
///
|
|
/// Resolves the path from the project root, handling different working directories.
|
|
#[allow(dead_code)]
|
|
pub fn get_project_root() -> String {
|
|
// Try to find project root by looking for Cargo.toml
|
|
let mut current = std::env::current_dir().unwrap();
|
|
|
|
// If we're in a subdirectory, go up until we find the workspace root
|
|
while !current.join("Cargo.toml").exists() || !current.join("test_data").exists() {
|
|
if !current.pop() {
|
|
// Fallback to relative path if we can't find root
|
|
return "../..".to_string();
|
|
}
|
|
}
|
|
|
|
current.to_string_lossy().to_string()
|
|
}
|
|
|
|
/// Get absolute path to the test DBN file
|
|
///
|
|
/// Resolves the path from the project root, handling different working directories.
|
|
#[allow(dead_code)]
|
|
pub fn get_dbn_test_file_path() -> String {
|
|
let root = get_project_root();
|
|
format!("{}/test_data/real/databento/ES.FUT_ohlcv-1m_2024-01-02.dbn", root)
|
|
}
|
|
|
|
/// Create a DBN-based market data repository for testing with real data
|
|
///
|
|
/// This function creates a repository that loads data from the real DBN file
|
|
/// in test_data/real/databento/.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// DbnMarketDataRepository configured with ES.FUT data for 2024-01-02
|
|
#[allow(dead_code)]
|
|
pub async fn create_dbn_repository() -> Result<Box<dyn MarketDataRepository>, anyhow::Error> {
|
|
use backtesting_service::dbn_repository::DbnMarketDataRepository;
|
|
use std::collections::HashMap;
|
|
|
|
let mut file_mapping = HashMap::new();
|
|
file_mapping.insert("ES.FUT".to_string(), get_dbn_test_file_path());
|
|
|
|
let repo = DbnMarketDataRepository::new(file_mapping).await?;
|
|
Ok(Box::new(repo))
|
|
}
|