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>
543 lines
20 KiB
Rust
543 lines
20 KiB
Rust
//! Test utilities and helper functions
|
|
//!
|
|
//! Common testing utilities shared across all test modules.
|
|
|
|
// CANONICAL TYPE IMPORTS - Use types::prelude::Decimal
|
|
use std::collections::HashMap;
|
|
use num_traits::FromPrimitive; // For Decimal::from_f64
|
|
|
|
/// Test data generators for consistent test scenarios
|
|
pub struct TestDataGenerator {
|
|
order_counter: std::sync::atomic::AtomicU64,
|
|
trade_counter: std::sync::atomic::AtomicU64,
|
|
}
|
|
|
|
impl TestDataGenerator {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
order_counter: std::sync::atomic::AtomicU64::new(1),
|
|
trade_counter: std::sync::atomic::AtomicU64::new(1),
|
|
}
|
|
}
|
|
|
|
pub fn generate_order(&self, symbol: &str, side: Side, quantity: u32, price: Option<f64>) -> Order {
|
|
let order_id = self.order_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
|
|
Order {
|
|
id: OrderId::new(format!("TEST-ORDER-{:06}", order_id)),
|
|
symbol: Symbol::new(symbol.to_string()).map_err(|e| format!("Failed to create symbol: {}", e)).unwrap(),
|
|
side,
|
|
order_type: if price.is_some() { OrderType::Limit } else { OrderType::Market },
|
|
quantity: Quantity::new(Decimal::from(quantity)).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(),
|
|
price: price.map(|p| Price::new(Decimal::from_f64_retain(p).unwrap()).map_err(|e| format!("Failed to create price: {}", e)).unwrap()),
|
|
time_in_force: TimeInForce::Day,
|
|
timestamp: chrono::Utc::now(),
|
|
status: OrderStatus::New,
|
|
client_id: ClientId::new("TEST-CLIENT".to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn generate_fill(&self, order: &Order, fill_quantity: Option<u32>, fill_price: Option<f64>) -> Fill {
|
|
let trade_id = self.trade_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
let fill_id = format!("FILL-{:06}", trade_id);
|
|
|
|
let quantity = fill_quantity
|
|
.map(|q| Quantity::new(Decimal::from(q)).map_err(|e| format!("Failed to create fill quantity: {}", e)).unwrap())
|
|
.unwrap_or(order.quantity.clone());
|
|
|
|
let price = fill_price
|
|
.map(|p| Price::new(Decimal::from_f64_retain(p).unwrap()).map_err(|e| format!("Failed to create fill price: {}", e)).unwrap())
|
|
.or_else(|| order.price.clone())
|
|
.unwrap_or_else(|| Price::new(Decimal::from(100)).map_err(|e| format!("Failed to create default price: {}", e)).unwrap());
|
|
|
|
Fill {
|
|
id: FillId::new(fill_id),
|
|
order_id: order.id.clone(),
|
|
trade_id: TradeId::new(format!("TRADE-{:06}", trade_id)),
|
|
symbol: order.symbol.clone(),
|
|
side: order.side,
|
|
quantity,
|
|
price,
|
|
timestamp: chrono::Utc::now(),
|
|
commission: Some(Money::new(Decimal::from_str("1.00").map_err(|e| format!("Failed to create commission decimal: {}", e)).unwrap(), Currency::USD)),
|
|
}
|
|
}
|
|
|
|
pub fn generate_position(&self, symbol: &str, quantity: i32, avg_price: f64) -> Position {
|
|
Position {
|
|
symbol: Symbol::new(symbol.to_string()).map_err(|e| format!("Failed to create symbol: {}", e)).unwrap(),
|
|
quantity: Quantity::new(Decimal::from(quantity.abs())).map_err(|e| format!("Failed to create quantity: {}", e)).unwrap(),
|
|
average_price: Price::new(Decimal::from_f64_retain(avg_price).unwrap()).map_err(|e| format!("Failed to create average price: {}", e)).unwrap(),
|
|
realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create realized PnL: {}", e)).unwrap(),
|
|
unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create unrealized PnL: {}", e)).unwrap(),
|
|
last_updated: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
pub fn generate_market_data_event(&self, symbol: &str, price: f64, volume: u64) -> MarketDataEvent {
|
|
MarketDataEvent {
|
|
symbol: Symbol::new(symbol.to_string()).map_err(|e| format!("Failed to create symbol: {}", e)).unwrap(),
|
|
price: Price::new(Decimal::from_f64_retain(price).unwrap()).map_err(|e| format!("Failed to create price: {}", e)).unwrap(),
|
|
timestamp: chrono::Utc::now(),
|
|
volume: Volume::new(Decimal::from(volume)).map_err(|e| format!("Failed to create volume: {}", e)).unwrap(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TestDataGenerator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Mock market data provider for testing
|
|
pub struct MockMarketDataProvider {
|
|
prices: HashMap<Symbol, Price>,
|
|
volumes: HashMap<Symbol, Volume>,
|
|
subscribers: Vec<tokio::sync::mpsc::UnboundedSender<MarketDataEvent>>,
|
|
}
|
|
|
|
impl MockMarketDataProvider {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
prices: HashMap::new(),
|
|
volumes: HashMap::new(),
|
|
subscribers: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn set_price(&mut self, symbol: Symbol, price: Price) {
|
|
self.prices.insert(symbol.clone(), price.clone());
|
|
|
|
let event = MarketDataEvent {
|
|
symbol,
|
|
price,
|
|
timestamp: chrono::Utc::now(),
|
|
volume: Volume::new(Decimal::from(1000)).map_err(|e| format!("Failed to create volume: {}", e)).unwrap(),
|
|
};
|
|
|
|
// Notify subscribers
|
|
self.subscribers.retain(|sender| {
|
|
sender.send(event.clone()).is_ok()
|
|
});
|
|
}
|
|
|
|
pub fn get_price(&self, symbol: &Symbol) -> Option<&Price> {
|
|
self.prices.get(symbol)
|
|
}
|
|
|
|
pub fn subscribe(&mut self) -> tokio::sync::mpsc::UnboundedReceiver<MarketDataEvent> {
|
|
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
|
self.subscribers.push(sender);
|
|
receiver
|
|
}
|
|
}
|
|
|
|
/// Test assertions for financial calculations
|
|
pub struct FinancialAssertions;
|
|
|
|
impl FinancialAssertions {
|
|
pub fn assert_price_equal(actual: &Price, expected: &Price, tolerance: Option<Decimal>) {
|
|
let tolerance = tolerance.unwrap_or_else(|| Decimal::from_str("0.01").map_err(|e| format!("Failed to create tolerance: {}", e)).unwrap());
|
|
let diff = (actual.value() - expected.value()).abs();
|
|
assert!(
|
|
diff <= tolerance,
|
|
"Price assertion failed: actual={}, expected={}, tolerance={}, diff={}",
|
|
actual.value(),
|
|
expected.value(),
|
|
tolerance,
|
|
diff
|
|
);
|
|
}
|
|
|
|
pub fn assert_quantity_equal(actual: &Quantity, expected: &Quantity, tolerance: Option<Decimal>) {
|
|
let tolerance = tolerance.unwrap_or_else(|| Decimal::from_str("0.000001").map_err(|e| format!("Failed to create tolerance: {}", e)).unwrap());
|
|
let diff = (actual.value() - expected.value()).abs();
|
|
assert!(
|
|
diff <= tolerance,
|
|
"Quantity assertion failed: actual={}, expected={}, tolerance={}, diff={}",
|
|
actual.value(),
|
|
expected.value(),
|
|
tolerance,
|
|
diff
|
|
);
|
|
}
|
|
|
|
pub fn assert_pnl_equal(actual: &PnL, expected: &PnL, tolerance: Option<Decimal>) {
|
|
let tolerance = tolerance.unwrap_or_else(|| Decimal::from_str("0.01").map_err(|e| format!("Failed to create tolerance: {}", e)).unwrap());
|
|
let diff = (actual.value() - expected.value()).abs();
|
|
assert!(
|
|
diff <= tolerance,
|
|
"PnL assertion failed: actual={}, expected={}, tolerance={}, diff={}",
|
|
actual.value(),
|
|
expected.value(),
|
|
tolerance,
|
|
diff
|
|
);
|
|
}
|
|
|
|
pub fn assert_money_equal(actual: &Money, expected: &Money, tolerance: Option<Decimal>) {
|
|
assert_eq!(actual.currency, expected.currency, "Currency mismatch");
|
|
|
|
let tolerance = tolerance.unwrap_or_else(|| Decimal::from_str("0.01").map_err(|e| format!("Failed to create tolerance: {}", e)).unwrap());
|
|
let diff = (actual.amount - expected.amount).abs();
|
|
assert!(
|
|
diff <= tolerance,
|
|
"Money assertion failed: actual={}, expected={}, tolerance={}, diff={}",
|
|
actual.amount,
|
|
expected.amount,
|
|
tolerance,
|
|
diff
|
|
);
|
|
}
|
|
|
|
pub fn assert_percentage_equal(actual: Decimal, expected: Decimal, tolerance: Option<Decimal>) {
|
|
let tolerance = tolerance.unwrap_or_else(|| Decimal::from_str("0.01").map_err(|e| format!("Failed to create tolerance: {}", e)).unwrap()); // 1%
|
|
let diff = (actual - expected).abs();
|
|
assert!(
|
|
diff <= tolerance,
|
|
"Percentage assertion failed: actual={}%, expected={}%, tolerance={}%, diff={}%",
|
|
actual * Decimal::from(100),
|
|
expected * Decimal::from(100),
|
|
tolerance * Decimal::from(100),
|
|
diff * Decimal::from(100)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Performance measurement utilities for tests
|
|
pub struct PerformanceMeasurement {
|
|
measurements: HashMap<String, std::time::Duration>,
|
|
}
|
|
|
|
impl PerformanceMeasurement {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
measurements: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn measure<F, R>(&mut self, name: &str, f: F) -> R
|
|
where
|
|
F: FnOnce() -> R,
|
|
{
|
|
let start = std::time::Instant::now();
|
|
let result = f();
|
|
let duration = start.elapsed();
|
|
|
|
self.measurements.insert(name.to_string(), duration);
|
|
result
|
|
}
|
|
|
|
pub async fn measure_async<F, Fut, R>(&mut self, name: &str, f: F) -> R
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
{
|
|
let start = std::time::Instant::now();
|
|
let result = f().await;
|
|
let duration = start.elapsed();
|
|
|
|
self.measurements.insert(name.to_string(), duration);
|
|
result
|
|
}
|
|
|
|
pub fn get_measurement(&self, name: &str) -> Option<std::time::Duration> {
|
|
self.measurements.get(name).copied()
|
|
}
|
|
|
|
pub fn assert_performance(&self, name: &str, max_duration: std::time::Duration) {
|
|
if let Some(actual) = self.get_measurement(name) {
|
|
assert!(
|
|
actual <= max_duration,
|
|
"Performance assertion failed for '{}': actual={:?}, max={:?}",
|
|
name, actual, max_duration
|
|
);
|
|
} else {
|
|
panic!("No measurement found for '{}'", name);
|
|
}
|
|
}
|
|
|
|
pub fn print_summary(&self) {
|
|
println!("\n=== Performance Summary ===");
|
|
for (name, duration) in &self.measurements {
|
|
println!("{}: {:?}", name, duration);
|
|
}
|
|
println!("==========================\n");
|
|
}
|
|
}
|
|
|
|
impl Default for PerformanceMeasurement {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Test scenario builders for complex integration tests
|
|
pub struct TestScenarioBuilder {
|
|
orders: Vec<Order>,
|
|
market_events: Vec<MarketDataEvent>,
|
|
expected_fills: Vec<Fill>,
|
|
generator: TestDataGenerator,
|
|
}
|
|
|
|
impl TestScenarioBuilder {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
orders: Vec::new(),
|
|
market_events: Vec::new(),
|
|
expected_fills: Vec::new(),
|
|
generator: TestDataGenerator::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_buy_order(mut self, symbol: &str, quantity: u32, price: f64) -> Self {
|
|
let order = self.generator.generate_order(symbol, Side::Buy, quantity, Some(price));
|
|
self.orders.push(order);
|
|
self
|
|
}
|
|
|
|
pub fn with_sell_order(mut self, symbol: &str, quantity: u32, price: f64) -> Self {
|
|
let order = self.generator.generate_order(symbol, Side::Sell, quantity, Some(price));
|
|
self.orders.push(order);
|
|
self
|
|
}
|
|
|
|
pub fn with_market_data(mut self, symbol: &str, price: f64, volume: u64) -> Self {
|
|
let event = self.generator.generate_market_data_event(symbol, price, volume);
|
|
self.market_events.push(event);
|
|
self
|
|
}
|
|
|
|
pub fn expect_fill(mut self, order_index: usize, quantity: Option<u32>, price: Option<f64>) -> Self {
|
|
if let Some(order) = self.orders.get(order_index) {
|
|
let fill = self.generator.generate_fill(order, quantity, price);
|
|
self.expected_fills.push(fill);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn build(self) -> TestScenario {
|
|
TestScenario {
|
|
orders: self.orders,
|
|
market_events: self.market_events,
|
|
expected_fills: self.expected_fills,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TestScenarioBuilder {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
pub struct TestScenario {
|
|
pub orders: Vec<Order>,
|
|
pub market_events: Vec<MarketDataEvent>,
|
|
pub expected_fills: Vec<Fill>,
|
|
}
|
|
|
|
/// Database test utilities
|
|
pub struct DatabaseTestUtils;
|
|
|
|
impl DatabaseTestUtils {
|
|
pub async fn setup_test_database() -> Result<String, Box<dyn std::error::Error>> {
|
|
// Return a test database connection string
|
|
// In a real implementation, this would set up a temporary database
|
|
Ok(std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| "postgresql://localhost/foxhunt_test".to_string()))
|
|
}
|
|
|
|
pub async fn cleanup_test_database(_connection_string: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
// Clean up test database
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn insert_test_data<T>(_connection: &str, _data: &[T]) -> Result<(), Box<dyn std::error::Error>>
|
|
where
|
|
T: serde::Serialize,
|
|
{
|
|
// Insert test data into database
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Concurrency test utilities
|
|
pub struct ConcurrencyTestUtils;
|
|
|
|
impl ConcurrencyTestUtils {
|
|
pub async fn run_concurrent_operations<F, Fut, R>(
|
|
operations: Vec<F>,
|
|
max_concurrent: usize,
|
|
) -> Vec<Result<R, Box<dyn std::error::Error + Send + Sync>>>
|
|
where
|
|
F: FnOnce() -> Fut + Send + 'static,
|
|
Fut: std::future::Future<Output = Result<R, Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
|
|
R: Send + 'static,
|
|
{
|
|
use tokio::sync::Semaphore;
|
|
use std::sync::Arc;
|
|
|
|
let semaphore = Arc::new(Semaphore::new(max_concurrent));
|
|
let mut handles = Vec::new();
|
|
|
|
for operation in operations {
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
let handle = tokio::spawn(async move {
|
|
let _permit = permit;
|
|
operation().await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut results = Vec::new();
|
|
for handle in handles {
|
|
match handle.await {
|
|
Ok(result) => results.push(result),
|
|
Err(e) => results.push(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)),
|
|
}
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
pub async fn stress_test_operation<F, Fut, R>(
|
|
operation: F,
|
|
num_iterations: usize,
|
|
max_concurrent: usize,
|
|
) -> StressTestResults<R>
|
|
where
|
|
F: Fn() -> Fut + Send + Sync + 'static,
|
|
Fut: std::future::Future<Output = Result<R, Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
|
|
R: Send + 'static,
|
|
F: Clone,
|
|
{
|
|
let start_time = std::time::Instant::now();
|
|
let operations = (0..num_iterations).map(|_| operation.clone()).collect();
|
|
let results = Self::run_concurrent_operations(operations, max_concurrent).await;
|
|
let total_duration = start_time.elapsed();
|
|
|
|
let successful = results.iter().filter(|r| r.is_ok()).count();
|
|
let failed = results.len() - successful;
|
|
|
|
StressTestResults {
|
|
total_operations: num_iterations,
|
|
successful_operations: successful,
|
|
failed_operations: failed,
|
|
total_duration,
|
|
operations_per_second: num_iterations as f64 / total_duration.as_secs_f64(),
|
|
results,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct StressTestResults<R> {
|
|
pub total_operations: usize,
|
|
pub successful_operations: usize,
|
|
pub failed_operations: usize,
|
|
pub total_duration: std::time::Duration,
|
|
pub operations_per_second: f64,
|
|
pub results: Vec<Result<R, Box<dyn std::error::Error + Send + Sync>>>,
|
|
}
|
|
|
|
impl<R> StressTestResults<R> {
|
|
pub fn success_rate(&self) -> f64 {
|
|
self.successful_operations as f64 / self.total_operations as f64
|
|
}
|
|
|
|
pub fn assert_success_rate(&self, minimum_rate: f64) {
|
|
let actual_rate = self.success_rate();
|
|
assert!(
|
|
actual_rate >= minimum_rate,
|
|
"Success rate too low: actual={:.2}%, minimum={:.2}%",
|
|
actual_rate * 100.0,
|
|
minimum_rate * 100.0
|
|
);
|
|
}
|
|
|
|
pub fn assert_throughput(&self, minimum_ops_per_sec: f64) {
|
|
assert!(
|
|
self.operations_per_second >= minimum_ops_per_sec,
|
|
"Throughput too low: actual={:.2} ops/sec, minimum={:.2} ops/sec",
|
|
self.operations_per_second,
|
|
minimum_ops_per_sec
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_data_generator() {
|
|
let generator = TestDataGenerator::new();
|
|
|
|
let order1 = generator.generate_order("AAPL", Side::Buy, 100, Some(150.0));
|
|
let order2 = generator.generate_order("MSFT", Side::Sell, 200, Some(300.0));
|
|
|
|
// Orders should have unique IDs
|
|
assert_ne!(order1.id.value(), order2.id.value());
|
|
|
|
// Order properties should match inputs
|
|
assert_eq!(order1.symbol.as_str(), "AAPL");
|
|
assert_eq!(order1.side, Side::Buy);
|
|
assert_eq!(order1.quantity.value(), Decimal::from(100));
|
|
assert_eq!(order1.price.as_ref().unwrap().value(), Decimal::from(150));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_financial_assertions() {
|
|
let price1 = Price::new(Decimal::from_str("150.00").unwrap()).map_err(|e| format!("Failed to create test price1: {}", e)).unwrap();
|
|
let price2 = Price::new(Decimal::from_str("150.01").unwrap()).map_err(|e| format!("Failed to create test price2: {}", e)).unwrap();
|
|
|
|
// Should pass with default tolerance
|
|
FinancialAssertions::assert_price_equal(&price1, &price2, None);
|
|
|
|
// Should pass with custom tolerance
|
|
FinancialAssertions::assert_price_equal(
|
|
&price1,
|
|
&price2,
|
|
Some(Decimal::from_str("0.02").map_err(|e| format!("Failed to create tolerance decimal: {}", e)).unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_performance_measurement() {
|
|
let mut perf = PerformanceMeasurement::new();
|
|
|
|
let result = perf.measure("test_operation", || {
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
42
|
|
});
|
|
|
|
assert_eq!(result, 42);
|
|
|
|
let measurement = perf.get_measurement("test_operation");
|
|
assert!(measurement.is_some());
|
|
assert!(measurement.unwrap() >= std::time::Duration::from_millis(10));
|
|
|
|
// Should assert performance within reasonable bounds
|
|
perf.assert_performance("test_operation", std::time::Duration::from_millis(50));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_scenario_builder() {
|
|
let scenario = TestScenarioBuilder::new()
|
|
.with_buy_order("AAPL", 100, 150.0)
|
|
.with_sell_order("AAPL", 50, 151.0)
|
|
.with_market_data("AAPL", 150.5, 10000)
|
|
.expect_fill(0, Some(50), Some(150.5))
|
|
.build();
|
|
|
|
assert_eq!(scenario.orders.len(), 2);
|
|
assert_eq!(scenario.market_events.len(), 1);
|
|
assert_eq!(scenario.expected_fills.len(), 1);
|
|
|
|
// Verify scenario structure
|
|
assert_eq!(scenario.orders[0].side, Side::Buy);
|
|
assert_eq!(scenario.orders[1].side, Side::Sell);
|
|
assert_eq!(scenario.market_events[0].symbol.as_str(), "AAPL");
|
|
assert_eq!(scenario.expected_fills[0].quantity.value(), Decimal::from(50));
|
|
}
|
|
} |