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>
443 lines
13 KiB
Rust
443 lines
13 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use common::{OrderSide, OrderType, Price, Quantity, Symbol, TimeInForce};
|
|
use rand::{thread_rng, Rng};
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
/// Test market data event structure
|
|
///
|
|
/// Note: This is a test-specific type, not the proto MarketDataEvent
|
|
#[derive(Debug, Clone)]
|
|
pub struct MarketDataEvent {
|
|
pub id: Uuid,
|
|
pub symbol: Symbol,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub bid: Price,
|
|
pub ask: Price,
|
|
pub bid_size: Quantity,
|
|
pub ask_size: Quantity,
|
|
pub last_price: Option<Price>,
|
|
pub volume: Option<Quantity>,
|
|
}
|
|
|
|
/// Test utilities for generating market data and orders
|
|
pub struct TestDataGenerator {
|
|
symbols: Vec<Symbol>,
|
|
prices: HashMap<Symbol, Price>,
|
|
}
|
|
|
|
impl Default for TestDataGenerator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl TestDataGenerator {
|
|
pub fn new() -> Self {
|
|
let symbols = vec![
|
|
Symbol::new("EURUSD".to_string()),
|
|
Symbol::new("GBPUSD".to_string()),
|
|
Symbol::new("USDJPY".to_string()),
|
|
Symbol::new("USDCHF".to_string()),
|
|
Symbol::new("AUDUSD".to_string()),
|
|
];
|
|
|
|
let mut prices = HashMap::new();
|
|
prices.insert(
|
|
Symbol::new("EURUSD".to_string()),
|
|
Price::new(1.0520).unwrap(),
|
|
); // 1.0520
|
|
prices.insert(
|
|
Symbol::new("GBPUSD".to_string()),
|
|
Price::new(1.2845).unwrap(),
|
|
); // 1.2845
|
|
prices.insert(
|
|
Symbol::new("USDJPY".to_string()),
|
|
Price::new(148.55).unwrap(),
|
|
); // 148.55
|
|
prices.insert(
|
|
Symbol::new("USDCHF".to_string()),
|
|
Price::new(0.8750).unwrap(),
|
|
); // 0.8750
|
|
prices.insert(
|
|
Symbol::new("AUDUSD".to_string()),
|
|
Price::new(0.6750).unwrap(),
|
|
); // 0.6750
|
|
|
|
Self { symbols, prices }
|
|
}
|
|
|
|
pub fn generate_market_data(&mut self, symbol: &Symbol) -> MarketDataEvent {
|
|
let mut rng = thread_rng();
|
|
let current_price = *self.prices.get(symbol).unwrap();
|
|
|
|
// Generate small random price movement
|
|
let change_pct = rng.gen_range(-0.001..0.001); // ±0.1%
|
|
let change = current_price.to_f64() * change_pct;
|
|
let new_price = Price::new(current_price.to_f64() + change).unwrap();
|
|
|
|
self.prices.insert(symbol.clone(), new_price);
|
|
|
|
MarketDataEvent {
|
|
id: Uuid::new_v4(),
|
|
symbol: symbol.clone(),
|
|
timestamp: Utc::now(),
|
|
bid: Price::new(new_price.to_f64() - 0.0002).unwrap(), // 2 pip spread
|
|
ask: new_price,
|
|
bid_size: Quantity::new(rng.gen_range(100000..1000000) as f64).unwrap(),
|
|
ask_size: Quantity::new(rng.gen_range(100000..1000000) as f64).unwrap(),
|
|
last_price: Some(new_price),
|
|
volume: Some(Quantity::new(rng.gen_range(50000..500000) as f64).unwrap()),
|
|
}
|
|
}
|
|
|
|
pub fn generate_order_request(&self, symbol: &Symbol) -> OrderRequest {
|
|
let mut rng = thread_rng();
|
|
let current_price = self.prices.get(symbol).unwrap();
|
|
|
|
OrderRequest {
|
|
id: Uuid::new_v4(),
|
|
symbol: symbol.clone(),
|
|
side: if rng.gen_bool(0.5) {
|
|
OrderSide::Buy
|
|
} else {
|
|
OrderSide::Sell
|
|
},
|
|
quantity: Quantity::new(rng.gen_range(10000..100000) as f64).unwrap(), // 10K to 100K units
|
|
order_type: OrderType::Market,
|
|
price: Some(*current_price),
|
|
time_in_force: TimeInForce::ImmediateOrCancel,
|
|
timestamp: Utc::now(),
|
|
}
|
|
}
|
|
|
|
pub fn get_random_symbol(&self) -> &Symbol {
|
|
let mut rng = thread_rng();
|
|
&self.symbols[rng.gen_range(0..self.symbols.len())]
|
|
}
|
|
|
|
pub fn get_all_symbols(&self) -> &[Symbol] {
|
|
&self.symbols
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OrderRequest {
|
|
pub id: Uuid,
|
|
pub symbol: Symbol,
|
|
pub side: OrderSide,
|
|
pub quantity: Quantity,
|
|
pub order_type: OrderType,
|
|
pub price: Option<Price>,
|
|
pub time_in_force: TimeInForce,
|
|
pub timestamp: DateTime<Utc>,
|
|
}
|
|
|
|
/// Trading event for database persistence testing
|
|
#[derive(Debug, Clone)]
|
|
pub struct TradingEvent {
|
|
pub id: Uuid,
|
|
pub event_type: String,
|
|
pub symbol: String,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub data: serde_json::Value,
|
|
}
|
|
|
|
/// Test assertion helpers
|
|
pub mod assertions {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
pub fn assert_within_tolerance(actual: f64, expected: f64, tolerance_pct: f64) {
|
|
let tolerance = expected * tolerance_pct;
|
|
assert!(
|
|
(actual - expected).abs() <= tolerance,
|
|
"Value {} is not within {}% tolerance of expected {}",
|
|
actual,
|
|
expected,
|
|
tolerance_pct * 100.0
|
|
);
|
|
}
|
|
|
|
pub fn assert_latency_under(duration: Duration, max_latency: Duration) {
|
|
assert!(
|
|
duration <= max_latency,
|
|
"Latency {:?} exceeds maximum allowed {:?}",
|
|
duration,
|
|
max_latency
|
|
);
|
|
}
|
|
|
|
pub fn assert_price_reasonable(price: &Price, symbol: &Symbol) {
|
|
let value = price.to_f64();
|
|
match symbol.as_str() {
|
|
"EURUSD" | "GBPUSD" | "AUDUSD" => {
|
|
assert!(
|
|
value > 0.5 && value < 2.0,
|
|
"Price {} unreasonable for {}",
|
|
value,
|
|
symbol
|
|
);
|
|
},
|
|
"USDJPY" => {
|
|
assert!(
|
|
value > 100.0 && value < 200.0,
|
|
"Price {} unreasonable for {}",
|
|
value,
|
|
symbol
|
|
);
|
|
},
|
|
"USDCHF" => {
|
|
assert!(
|
|
value > 0.7 && value < 1.2,
|
|
"Price {} unreasonable for {}",
|
|
value,
|
|
symbol
|
|
);
|
|
},
|
|
_ => {}, // Skip validation for unknown symbols
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Performance profiling utilities for E2E tests
|
|
pub struct PerformanceProfiler {
|
|
start_time: std::time::Instant,
|
|
checkpoints: Vec<(String, std::time::Duration)>,
|
|
}
|
|
|
|
impl Default for PerformanceProfiler {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl PerformanceProfiler {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
start_time: std::time::Instant::now(),
|
|
checkpoints: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn checkpoint(&mut self, name: &str) {
|
|
let elapsed = self.start_time.elapsed();
|
|
self.checkpoints.push((name.to_string(), elapsed));
|
|
}
|
|
|
|
pub fn print_summary(&self) {
|
|
println!("\n=== Performance Profile ===");
|
|
let mut last_duration = std::time::Duration::ZERO;
|
|
for (name, duration) in &self.checkpoints {
|
|
let delta = *duration - last_duration;
|
|
println!("{}: {:?} (delta: {:?})", name, duration, delta);
|
|
last_duration = *duration;
|
|
}
|
|
println!("Total: {:?}\n", self.start_time.elapsed());
|
|
}
|
|
}
|
|
|
|
/// Test utility functions
|
|
pub struct TestUtils;
|
|
|
|
impl TestUtils {
|
|
/// Setup test logging
|
|
pub fn setup_test_logging() {
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.try_init();
|
|
}
|
|
|
|
/// Wait for a condition to be true with timeout
|
|
pub async fn wait_for_condition<F, Fut>(
|
|
condition: F,
|
|
timeout_secs: u64,
|
|
check_interval_ms: u64,
|
|
) -> anyhow::Result<()>
|
|
where
|
|
F: Fn() -> Fut,
|
|
Fut: std::future::Future<Output = bool>,
|
|
{
|
|
use tokio::time::{sleep, Duration, Instant};
|
|
|
|
let start = Instant::now();
|
|
let timeout = Duration::from_secs(timeout_secs);
|
|
let interval = Duration::from_millis(check_interval_ms);
|
|
|
|
while start.elapsed() < timeout {
|
|
if condition().await {
|
|
return Ok(());
|
|
}
|
|
sleep(interval).await;
|
|
}
|
|
|
|
Err(anyhow::anyhow!(
|
|
"Condition not met within {} seconds",
|
|
timeout_secs
|
|
))
|
|
}
|
|
|
|
/// Check if a service is healthy via HTTP
|
|
pub async fn check_service_health(endpoint: &str) -> anyhow::Result<bool> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.build()?;
|
|
|
|
match client.get(endpoint).send().await {
|
|
Ok(response) => Ok(response.status().is_success()),
|
|
Err(_) => Ok(false),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stub types for E2E testing - These are lightweight test doubles
|
|
/// for performance-critical components that are tested separately
|
|
|
|
/// Trading operations tracker for latency testing
|
|
pub struct TradingOperations {
|
|
operations: std::sync::Arc<std::sync::Mutex<Vec<(String, String, std::time::Instant)>>>,
|
|
}
|
|
|
|
impl Default for TradingOperations {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl TradingOperations {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
operations: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
pub fn record_order_submission(&self, order_id: String, symbol: String) {
|
|
let mut ops = self.operations.lock().unwrap();
|
|
ops.push((order_id, symbol, std::time::Instant::now()));
|
|
}
|
|
}
|
|
|
|
/// SIMD price operations stub for testing (actual implementation in trading_engine)
|
|
pub struct SimdPriceOps;
|
|
|
|
impl SimdPriceOps {
|
|
pub fn new() -> anyhow::Result<Self> {
|
|
Ok(Self)
|
|
}
|
|
|
|
pub fn vectorized_mean(&self, prices: &[f64]) -> anyhow::Result<f64> {
|
|
if prices.is_empty() {
|
|
return Err(anyhow::anyhow!("Empty price array"));
|
|
}
|
|
Ok(prices.iter().sum::<f64>() / prices.len() as f64)
|
|
}
|
|
}
|
|
|
|
/// Lock-free ring buffer stub for testing
|
|
pub struct LockFreeRingBuffer<T> {
|
|
buffer: std::sync::Arc<std::sync::Mutex<Vec<T>>>,
|
|
capacity: usize,
|
|
}
|
|
|
|
impl<T: Clone> LockFreeRingBuffer<T> {
|
|
pub fn new(capacity: usize) -> Self {
|
|
Self {
|
|
buffer: std::sync::Arc::new(std::sync::Mutex::new(Vec::with_capacity(capacity))),
|
|
capacity,
|
|
}
|
|
}
|
|
|
|
pub fn try_push(&self, item: T) -> bool {
|
|
let mut buffer = self.buffer.lock().unwrap();
|
|
if buffer.len() < self.capacity {
|
|
buffer.push(item);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Small batch processor for testing
|
|
pub struct SmallBatchProcessor;
|
|
|
|
impl Default for SmallBatchProcessor {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl SmallBatchProcessor {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
pub fn process_batch<T>(&self, orders: Vec<T>) -> anyhow::Result<usize> {
|
|
// Simulate minimal processing - generic to accept any order type
|
|
Ok(orders.len())
|
|
}
|
|
}
|
|
|
|
/// Environment setup utilities
|
|
pub mod env {
|
|
use std::env;
|
|
|
|
pub fn setup_test_environment() {
|
|
// Set up test-specific environment variables
|
|
env::set_var("RUST_LOG", "debug");
|
|
env::set_var("DATABASE_URL", get_test_database_url());
|
|
env::set_var("TRADING_SERVICE_PORT", "50051");
|
|
env::set_var("BACKTESTING_SERVICE_PORT", "50052");
|
|
env::set_var("CONFIG_SERVICE_PORT", "50053");
|
|
}
|
|
|
|
pub fn get_test_database_url() -> String {
|
|
env::var("TEST_DATABASE_URL")
|
|
.unwrap_or_else(|_| "postgresql://localhost/foxhunt_test".to_string())
|
|
}
|
|
|
|
pub fn is_ci_environment() -> bool {
|
|
env::var("CI").is_ok() || env::var("GITHUB_ACTIONS").is_ok()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn test_data_generator_creates_valid_market_data() {
|
|
let mut generator = TestDataGenerator::new();
|
|
let symbol = Symbol::new("EURUSD".to_string());
|
|
let market_data = generator.generate_market_data(&symbol);
|
|
|
|
assert_eq!(market_data.symbol, symbol);
|
|
assert!(market_data.bid < market_data.ask);
|
|
assertions::assert_price_reasonable(&market_data.bid, &symbol);
|
|
assertions::assert_price_reasonable(&market_data.ask, &symbol);
|
|
}
|
|
|
|
#[test]
|
|
fn test_order_request_generation() {
|
|
let generator = TestDataGenerator::new();
|
|
let symbol = Symbol::new("GBPUSD".to_string());
|
|
let order = generator.generate_order_request(&symbol);
|
|
|
|
assert_eq!(order.symbol, symbol);
|
|
assert!(order.quantity.to_f64() > 0.0);
|
|
if let Some(price) = &order.price {
|
|
assertions::assert_price_reasonable(price, &symbol);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_assertion_helpers() {
|
|
assertions::assert_within_tolerance(100.0, 99.0, 0.02); // 2% tolerance
|
|
assertions::assert_latency_under(Duration::from_millis(5), Duration::from_millis(10));
|
|
}
|
|
}
|