Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
534 lines
19 KiB
Rust
534 lines
19 KiB
Rust
//! Dual Provider Test Utilities
|
|
//!
|
|
//! Specialized utility functions for testing dual-provider (Databento/Benzinga) scenarios
|
|
|
|
use anyhow::Result;
|
|
use serde_json::{json, Value};
|
|
use std::collections::HashMap;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use tokio::time::sleep;
|
|
use tracing::{info, warn};
|
|
|
|
use crate::mocks::DualProviderMockOrchestrator;
|
|
|
|
/// Dual-provider test configuration
|
|
#[derive(Debug, Clone)]
|
|
pub struct DualProviderTestConfig {
|
|
pub databento_enabled: bool,
|
|
pub benzinga_enabled: bool,
|
|
pub failover_timeout: Duration,
|
|
pub data_consistency_threshold: f64,
|
|
pub latency_threshold: Duration,
|
|
pub mock_mode: bool,
|
|
}
|
|
|
|
impl Default for DualProviderTestConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
databento_enabled: true,
|
|
benzinga_enabled: true,
|
|
failover_timeout: Duration::from_secs(5),
|
|
data_consistency_threshold: 0.8,
|
|
latency_threshold: Duration::from_millis(100),
|
|
mock_mode: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test scenario for dual-provider testing
|
|
#[derive(Debug, Clone)]
|
|
pub struct DualProviderTestScenario {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub symbols: Vec<String>,
|
|
pub duration: Duration,
|
|
pub expected_events_min: usize,
|
|
pub test_failover: bool,
|
|
pub test_consistency: bool,
|
|
pub test_performance: bool,
|
|
}
|
|
|
|
impl DualProviderTestScenario {
|
|
/// Create a basic market data streaming test scenario
|
|
pub fn basic_streaming() -> Self {
|
|
Self {
|
|
name: "basic_streaming".to_string(),
|
|
description: "Test basic market data streaming from both providers".to_string(),
|
|
symbols: vec!["AAPL".to_string(), "GOOGL".to_string()],
|
|
duration: Duration::from_secs(10),
|
|
expected_events_min: 5,
|
|
test_failover: false,
|
|
test_consistency: false,
|
|
test_performance: false,
|
|
}
|
|
}
|
|
|
|
/// Create a failover test scenario
|
|
pub fn failover_test() -> Self {
|
|
Self {
|
|
name: "failover_test".to_string(),
|
|
description: "Test provider failover mechanisms".to_string(),
|
|
symbols: vec!["AAPL".to_string()],
|
|
duration: Duration::from_secs(15),
|
|
expected_events_min: 3,
|
|
test_failover: true,
|
|
test_consistency: false,
|
|
test_performance: false,
|
|
}
|
|
}
|
|
|
|
/// Create a data consistency test scenario
|
|
pub fn consistency_test() -> Self {
|
|
Self {
|
|
name: "consistency_test".to_string(),
|
|
description: "Test data consistency between providers".to_string(),
|
|
symbols: vec!["AAPL".to_string(), "MSFT".to_string()],
|
|
duration: Duration::from_secs(20),
|
|
expected_events_min: 10,
|
|
test_failover: false,
|
|
test_consistency: true,
|
|
test_performance: false,
|
|
}
|
|
}
|
|
|
|
/// Create a performance test scenario
|
|
pub fn performance_test() -> Self {
|
|
Self {
|
|
name: "performance_test".to_string(),
|
|
description: "Test performance and latency of dual providers".to_string(),
|
|
symbols: vec!["AAPL".to_string(), "GOOGL".to_string(), "MSFT".to_string()],
|
|
duration: Duration::from_secs(30),
|
|
expected_events_min: 20,
|
|
test_failover: false,
|
|
test_consistency: false,
|
|
test_performance: true,
|
|
}
|
|
}
|
|
|
|
/// Create a comprehensive test scenario
|
|
pub fn comprehensive() -> Self {
|
|
Self {
|
|
name: "comprehensive".to_string(),
|
|
description: "Comprehensive test including all aspects".to_string(),
|
|
symbols: vec!["AAPL".to_string(), "GOOGL".to_string(), "MSFT".to_string(), "TSLA".to_string()],
|
|
duration: Duration::from_secs(45),
|
|
expected_events_min: 30,
|
|
test_failover: true,
|
|
test_consistency: true,
|
|
test_performance: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test result for dual-provider scenarios
|
|
#[derive(Debug, Clone)]
|
|
pub struct DualProviderTestResult {
|
|
pub scenario_name: String,
|
|
pub success: bool,
|
|
pub duration: Duration,
|
|
pub events_received: usize,
|
|
pub databento_events: usize,
|
|
pub benzinga_events: usize,
|
|
pub average_latency: Option<Duration>,
|
|
pub max_latency: Option<Duration>,
|
|
pub consistency_score: Option<f64>,
|
|
pub failover_time: Option<Duration>,
|
|
pub error_message: Option<String>,
|
|
pub metrics: HashMap<String, f64>,
|
|
}
|
|
|
|
impl DualProviderTestResult {
|
|
pub fn success(scenario_name: String, duration: Duration) -> Self {
|
|
Self {
|
|
scenario_name,
|
|
success: true,
|
|
duration,
|
|
events_received: 0,
|
|
databento_events: 0,
|
|
benzinga_events: 0,
|
|
average_latency: None,
|
|
max_latency: None,
|
|
consistency_score: None,
|
|
failover_time: None,
|
|
error_message: None,
|
|
metrics: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn failure(scenario_name: String, duration: Duration, error: String) -> Self {
|
|
Self {
|
|
scenario_name,
|
|
success: false,
|
|
duration,
|
|
events_received: 0,
|
|
databento_events: 0,
|
|
benzinga_events: 0,
|
|
average_latency: None,
|
|
max_latency: None,
|
|
consistency_score: None,
|
|
failover_time: None,
|
|
error_message: Some(error),
|
|
metrics: HashMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dual-provider test utilities
|
|
pub struct DualProviderTestUtils;
|
|
|
|
impl DualProviderTestUtils {
|
|
/// Setup mock providers for testing
|
|
pub async fn setup_mock_providers() -> Result<DualProviderMockOrchestrator> {
|
|
info!("Setting up mock dual providers");
|
|
|
|
let orchestrator = DualProviderMockOrchestrator::new();
|
|
orchestrator.start_all().await?;
|
|
|
|
// Wait for providers to be ready
|
|
sleep(Duration::from_secs(1)).await;
|
|
|
|
let status = orchestrator.get_provider_status().await;
|
|
if !status["databento"] || !status["benzinga"] {
|
|
anyhow::bail!("Failed to start mock providers");
|
|
}
|
|
|
|
info!("✅ Mock dual providers ready");
|
|
Ok(orchestrator)
|
|
}
|
|
|
|
/// Cleanup mock providers
|
|
pub async fn cleanup_mock_providers(orchestrator: DualProviderMockOrchestrator) -> Result<()> {
|
|
info!("Cleaning up mock dual providers");
|
|
orchestrator.stop_all().await?;
|
|
info!("✅ Mock dual providers cleaned up");
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate market data event structure
|
|
pub fn validate_market_data_event(event: &Value, expected_provider: Option<&str>) -> Result<()> {
|
|
// Check required fields
|
|
let required_fields = ["symbol", "provider", "bid", "ask", "timestamp"];
|
|
for field in &required_fields {
|
|
if event.get(field).is_none() {
|
|
anyhow::bail!("Missing required field: {}", field);
|
|
}
|
|
}
|
|
|
|
// Validate provider if specified
|
|
if let Some(provider) = expected_provider {
|
|
let actual_provider = event.get("provider").unwrap().as_str().unwrap();
|
|
if actual_provider != provider {
|
|
anyhow::bail!("Expected provider '{}', got '{}'", provider, actual_provider);
|
|
}
|
|
}
|
|
|
|
// Validate price data
|
|
let bid = event.get("bid").unwrap().as_str().unwrap().parse::<f64>()?;
|
|
let ask = event.get("ask").unwrap().as_str().unwrap().parse::<f64>()?;
|
|
|
|
if bid <= 0.0 || ask <= 0.0 {
|
|
anyhow::bail!("Invalid price data: bid={}, ask={}", bid, ask);
|
|
}
|
|
|
|
if ask <= bid {
|
|
anyhow::bail!("Invalid spread: ask ({}) <= bid ({})", ask, bid);
|
|
}
|
|
|
|
// Validate timestamp
|
|
let timestamp = event.get("timestamp").unwrap().as_u64().unwrap();
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as u64;
|
|
|
|
if timestamp > now {
|
|
warn!("Future timestamp detected: event={}, now={}", timestamp, now);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate news event structure
|
|
pub fn validate_news_event(event: &Value) -> Result<()> {
|
|
let required_fields = ["headline", "provider", "symbols", "timestamp", "sentiment_score"];
|
|
for field in &required_fields {
|
|
if event.get(field).is_none() {
|
|
anyhow::bail!("Missing required field: {}", field);
|
|
}
|
|
}
|
|
|
|
// Validate provider is Benzinga for news
|
|
let provider = event.get("provider").unwrap().as_str().unwrap();
|
|
if provider != "benzinga" {
|
|
anyhow::bail!("News should come from Benzinga, got: {}", provider);
|
|
}
|
|
|
|
// Validate sentiment score
|
|
let sentiment = event.get("sentiment_score").unwrap().as_f64().unwrap();
|
|
if sentiment.abs() > 1.0 {
|
|
anyhow::bail!("Invalid sentiment score: {}", sentiment);
|
|
}
|
|
|
|
// Validate symbols array
|
|
let symbols = event.get("symbols").unwrap().as_array().unwrap();
|
|
if symbols.is_empty() {
|
|
anyhow::bail!("News event must have at least one symbol");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Calculate data consistency score between two datasets
|
|
pub fn calculate_consistency_score(
|
|
databento_data: &[Value],
|
|
benzinga_data: &[Value],
|
|
time_window: Duration,
|
|
) -> f64 {
|
|
if databento_data.is_empty() || benzinga_data.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
let mut matching_pairs = 0;
|
|
let mut total_pairs = 0;
|
|
|
|
for db_event in databento_data {
|
|
if let Some(db_timestamp) = db_event.get("timestamp").and_then(|t| t.as_u64()) {
|
|
if let Some(db_symbol) = db_event.get("symbol").and_then(|s| s.as_str()) {
|
|
// Look for corresponding Benzinga event within time window
|
|
for bz_event in benzinga_data {
|
|
if let Some(bz_timestamp) = bz_event.get("timestamp").and_then(|t| t.as_u64()) {
|
|
if let Some(bz_symbol) = bz_event.get("symbol").and_then(|s| s.as_str()) {
|
|
if db_symbol == bz_symbol {
|
|
let time_diff = if db_timestamp > bz_timestamp {
|
|
db_timestamp - bz_timestamp
|
|
} else {
|
|
bz_timestamp - db_timestamp
|
|
};
|
|
|
|
if Duration::from_nanos(time_diff) <= time_window {
|
|
total_pairs += 1;
|
|
|
|
// Check price consistency (within 1% tolerance)
|
|
if let (Some(db_price), Some(bz_price)) = (
|
|
db_event.get("last").and_then(|p| p.as_str()).and_then(|s| s.parse::<f64>().ok()),
|
|
bz_event.get("last").and_then(|p| p.as_str()).and_then(|s| s.parse::<f64>().ok())
|
|
) {
|
|
let price_diff = (db_price - bz_price).abs() / db_price;
|
|
if price_diff <= 0.01 { // 1% tolerance
|
|
matching_pairs += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if total_pairs == 0 {
|
|
0.0
|
|
} else {
|
|
matching_pairs as f64 / total_pairs as f64
|
|
}
|
|
}
|
|
|
|
/// Measure latency for a series of operations
|
|
pub fn measure_latencies<T>(operations: Vec<T>, operation_fn: impl Fn(T) -> Duration) -> (Duration, Duration, Duration) {
|
|
let latencies: Vec<Duration> = operations.into_iter().map(operation_fn).collect();
|
|
|
|
if latencies.is_empty() {
|
|
return (Duration::from_nanos(0), Duration::from_nanos(0), Duration::from_nanos(0));
|
|
}
|
|
|
|
let total_nanos: u64 = latencies.iter().map(|d| d.as_nanos() as u64).sum();
|
|
let avg_latency = Duration::from_nanos(total_nanos / latencies.len() as u64);
|
|
|
|
let max_latency = latencies.iter().max().copied().unwrap_or(Duration::from_nanos(0));
|
|
|
|
// Calculate P95 latency
|
|
let mut sorted_latencies = latencies;
|
|
sorted_latencies.sort();
|
|
let p95_index = (sorted_latencies.len() as f64 * 0.95) as usize;
|
|
let p95_latency = sorted_latencies.get(p95_index).copied().unwrap_or(Duration::from_nanos(0));
|
|
|
|
(avg_latency, max_latency, p95_latency)
|
|
}
|
|
|
|
/// Generate test configuration for dual providers
|
|
pub fn generate_test_configuration() -> Value {
|
|
json!({
|
|
"data_sources": {
|
|
"databento": {
|
|
"enabled": true,
|
|
"api_key": "test_databento_key",
|
|
"base_url": "http://127.0.0.1:3001",
|
|
"timeout_ms": 5000,
|
|
"retry_attempts": 3,
|
|
"symbols": ["AAPL", "GOOGL", "MSFT", "TSLA"]
|
|
},
|
|
"benzinga": {
|
|
"enabled": true,
|
|
"api_key": "test_benzinga_key",
|
|
"base_url": "http://127.0.0.1:3002",
|
|
"timeout_ms": 5000,
|
|
"retry_attempts": 3,
|
|
"symbols": ["AAPL", "GOOGL", "MSFT", "TSLA"]
|
|
},
|
|
"priority": {
|
|
"market_data": ["databento", "benzinga"],
|
|
"news": ["benzinga"],
|
|
"historical": ["databento", "benzinga"]
|
|
},
|
|
"failover": {
|
|
"enabled": true,
|
|
"timeout_ms": 5000,
|
|
"retry_delay_ms": 1000,
|
|
"max_retries": 3
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Wait for provider to become healthy
|
|
pub async fn wait_for_provider_health(
|
|
check_fn: impl Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>,
|
|
timeout: Duration,
|
|
check_interval: Duration,
|
|
) -> bool {
|
|
let start = Instant::now();
|
|
|
|
while start.elapsed() < timeout {
|
|
if check_fn().await {
|
|
return true;
|
|
}
|
|
|
|
sleep(check_interval).await;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Create test summary from multiple test results
|
|
pub fn create_test_summary(results: &[DualProviderTestResult]) -> Value {
|
|
let total_tests = results.len();
|
|
let successful_tests = results.iter().filter(|r| r.success).count();
|
|
let failed_tests = total_tests - successful_tests;
|
|
|
|
let total_events: usize = results.iter().map(|r| r.events_received).sum();
|
|
let total_databento_events: usize = results.iter().map(|r| r.databento_events).sum();
|
|
let total_benzinga_events: usize = results.iter().map(|r| r.benzinga_events).sum();
|
|
|
|
let avg_latency = if results.iter().any(|r| r.average_latency.is_some()) {
|
|
let latencies: Vec<Duration> = results.iter().filter_map(|r| r.average_latency).collect();
|
|
if !latencies.is_empty() {
|
|
let total_nanos: u64 = latencies.iter().map(|d| d.as_nanos() as u64).sum();
|
|
Some(Duration::from_nanos(total_nanos / latencies.len() as u64))
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let avg_consistency = if results.iter().any(|r| r.consistency_score.is_some()) {
|
|
let scores: Vec<f64> = results.iter().filter_map(|r| r.consistency_score).collect();
|
|
if !scores.is_empty() {
|
|
Some(scores.iter().sum::<f64>() / scores.len() as f64)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
json!({
|
|
"summary": {
|
|
"total_tests": total_tests,
|
|
"successful_tests": successful_tests,
|
|
"failed_tests": failed_tests,
|
|
"success_rate": if total_tests > 0 { successful_tests as f64 / total_tests as f64 } else { 0.0 }
|
|
},
|
|
"events": {
|
|
"total": total_events,
|
|
"databento": total_databento_events,
|
|
"benzinga": total_benzinga_events,
|
|
"databento_percentage": if total_events > 0 { total_databento_events as f64 / total_events as f64 * 100.0 } else { 0.0 },
|
|
"benzinga_percentage": if total_events > 0 { total_benzinga_events as f64 / total_events as f64 * 100.0 } else { 0.0 }
|
|
},
|
|
"performance": {
|
|
"average_latency_ms": avg_latency.map(|d| d.as_millis()),
|
|
"consistency_score": avg_consistency
|
|
},
|
|
"test_results": results
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_validate_market_data_event() {
|
|
let valid_event = json!({
|
|
"symbol": "AAPL",
|
|
"provider": "databento",
|
|
"bid": "149.95",
|
|
"ask": "150.05",
|
|
"timestamp": 1640995200000u64
|
|
});
|
|
|
|
assert!(DualProviderTestUtils::validate_market_data_event(&valid_event, Some("databento")).is_ok());
|
|
|
|
let invalid_event = json!({
|
|
"symbol": "AAPL",
|
|
"provider": "databento"
|
|
// Missing required fields
|
|
});
|
|
|
|
assert!(DualProviderTestUtils::validate_market_data_event(&invalid_event, None).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_consistency_score() {
|
|
let databento_data = vec![
|
|
json!({
|
|
"symbol": "AAPL",
|
|
"last": "150.00",
|
|
"timestamp": 1640995200000u64
|
|
})
|
|
];
|
|
|
|
let benzinga_data = vec![
|
|
json!({
|
|
"symbol": "AAPL",
|
|
"last": "150.10",
|
|
"timestamp": 1640995201000u64
|
|
})
|
|
];
|
|
|
|
let score = DualProviderTestUtils::calculate_consistency_score(
|
|
&databento_data,
|
|
&benzinga_data,
|
|
Duration::from_secs(5)
|
|
);
|
|
|
|
assert!(score >= 0.0 && score <= 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_test_scenarios() {
|
|
let basic = DualProviderTestScenario::basic_streaming();
|
|
assert_eq!(basic.name, "basic_streaming");
|
|
assert!(!basic.test_failover);
|
|
|
|
let failover = DualProviderTestScenario::failover_test();
|
|
assert_eq!(failover.name, "failover_test");
|
|
assert!(failover.test_failover);
|
|
|
|
let comprehensive = DualProviderTestScenario::comprehensive();
|
|
assert_eq!(comprehensive.name, "comprehensive");
|
|
assert!(comprehensive.test_failover);
|
|
assert!(comprehensive.test_consistency);
|
|
assert!(comprehensive.test_performance);
|
|
}
|
|
} |