//! Trading arena for multi-agent simulation. use algoarena_shared::{AgentResult, MarketStats, TournamentConfig, TournamentResult}; use crate::AlgoArenaError; use crate::agents::{TradingAgent, create_agent}; use crate::market::MarketSimulator; /// Trading arena orchestrating multi-agent tournaments. #[derive(Debug, Default)] pub struct TradingArena { /// Current step. step: usize, } impl TradingArena { /// Create a new trading arena. #[must_use] pub fn new() -> Self { Self { step: 0 } } /// Run a tournament. pub fn run(&mut self, config: &TournamentConfig) -> Result { let seed = config.seed.unwrap_or(42); let num_assets = config.assets.len(); // Create market simulator let mut market = MarketSimulator::new(config.assets.clone(), config.transaction_cost_bps, seed); // Create agents let mut agents: Vec> = config .agents .iter() .map(|agent_config| create_agent(agent_config.clone(), num_assets)) .collect(); // Run simulation for step in 0..config.num_steps { self.step = step; // Advance market market.step(); // Get market state let state = market.get_state(); // Each agent observes and decides for agent in &mut agents { agent.observe(&state); } // Collect orders from all agents let mut all_orders = Vec::new(); for agent in &agents { let orders = agent.decide(&state); all_orders.extend(orders); } // Execute orders and update agents for order in &all_orders { let execution = market.execute_order(order); // Find the agent and update it for agent in &mut agents { if agent.id() == order.agent_id { agent.update(&[execution.clone()]); break; } } } } // Calculate results let rankings = self.calculate_rankings(&agents, &config.agents); let price_paths = market.price_history().to_vec(); let market_stats = self.calculate_market_stats(config, &price_paths); Ok(TournamentResult { config: config.clone(), rankings, price_paths, market_stats, }) } /// Calculate agent rankings. fn calculate_rankings( &self, agents: &[Box], configs: &[algoarena_shared::AgentConfig], ) -> Vec { let mut results: Vec = agents .iter() .map(|agent| { let state = agent.get_state(); let config = configs.iter().find(|c| c.id == state.agent_id).unwrap(); let initial_capital = config.initial_capital; let total_return = (state.portfolio_value - initial_capital) / initial_capital * 100.0; let sharpe_ratio = self.calculate_sharpe(&state.pnl_history); let max_drawdown = self.calculate_max_drawdown(&state.pnl_history); let win_rate = self.calculate_win_rate(&state.pnl_history); AgentResult { agent_id: state.agent_id, agent_name: config.name.clone(), strategy: config.strategy, rank: 0, // Will be set after sorting final_value: state.portfolio_value, total_return, sharpe_ratio, max_drawdown, win_rate, trade_count: state.trade_count, pnl_history: state.pnl_history, } }) .collect(); // Sort by total return (descending) results.sort_by(|a, b| { b.total_return .partial_cmp(&a.total_return) .unwrap_or(std::cmp::Ordering::Equal) }); // Assign ranks for (i, result) in results.iter_mut().enumerate() { result.rank = i + 1; } results } /// Calculate Sharpe ratio from P&L history. fn calculate_sharpe(&self, pnl_history: &[f64]) -> f64 { if pnl_history.len() < 2 { return 0.0; } // Calculate returns let returns: Vec = pnl_history .windows(2) .map(|w| (w[1] - w[0]) / w[0]) .collect(); if returns.is_empty() { return 0.0; } let mean: f64 = returns.iter().sum::() / returns.len() as f64; let variance: f64 = returns.iter().map(|r| (r - mean).powi(2)).sum::() / returns.len() as f64; let std_dev = variance.sqrt(); if std_dev < 1e-10 { return 0.0; } // Annualized (assuming 252 trading days) mean / std_dev * (252.0_f64).sqrt() } /// Calculate maximum drawdown from P&L history. fn calculate_max_drawdown(&self, pnl_history: &[f64]) -> f64 { if pnl_history.is_empty() { return 0.0; } let mut peak = pnl_history[0]; let mut max_dd: f64 = 0.0; for &value in pnl_history { if value > peak { peak = value; } let drawdown = (peak - value) / peak * 100.0; if drawdown > max_dd { max_dd = drawdown; } } max_dd } /// Calculate win rate from P&L history. fn calculate_win_rate(&self, pnl_history: &[f64]) -> f64 { if pnl_history.len() < 2 { return 0.0; } let returns: Vec = pnl_history .windows(2) .map(|w| (w[1] - w[0]) / w[0]) .collect(); if returns.is_empty() { return 0.0; } let wins = returns.iter().filter(|&&r| r > 0.0).count(); wins as f64 / returns.len() as f64 * 100.0 } /// Calculate market statistics. fn calculate_market_stats( &self, config: &TournamentConfig, price_paths: &[Vec], ) -> MarketStats { let mut asset_returns: Vec<(String, f64)> = config .assets .iter() .zip(price_paths.iter()) .map(|(asset, prices)| { let initial = prices.first().unwrap_or(&1.0); let final_price = prices.last().unwrap_or(&1.0); let ret = (final_price - initial) / initial * 100.0; (asset.symbol.clone(), ret) }) .collect(); asset_returns.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let best_asset = asset_returns .first() .map(|(s, _)| s.clone()) .unwrap_or_default(); let worst_asset = asset_returns .last() .map(|(s, _)| s.clone()) .unwrap_or_default(); let market_return = asset_returns.iter().map(|(_, r)| r).sum::() / asset_returns.len().max(1) as f64; // Calculate market volatility from first asset let market_volatility = if let Some(prices) = price_paths.first() { self.calculate_volatility(prices) } else { 0.0 }; MarketStats { market_return, market_volatility, best_asset, worst_asset, } } /// Calculate annualized volatility from price series. fn calculate_volatility(&self, prices: &[f64]) -> f64 { if prices.len() < 2 { return 0.0; } let returns: Vec = prices.windows(2).map(|w| (w[1] / w[0]).ln()).collect(); if returns.is_empty() { return 0.0; } let mean: f64 = returns.iter().sum::() / returns.len() as f64; let variance: f64 = returns.iter().map(|r| (r - mean).powi(2)).sum::() / returns.len() as f64; variance.sqrt() * (252.0_f64).sqrt() * 100.0 // Annualized % } } #[cfg(test)] mod tests { use super::*; use algoarena_shared::{ AgentConfig, AgentParameters, AssetConfig, StrategyType, sample_tournament, }; #[test] fn test_arena_creation() { let arena = TradingArena::new(); assert_eq!(arena.step, 0); } #[test] fn test_run_tournament() { let mut arena = TradingArena::new(); let config = sample_tournament(); let result = arena.run(&config); assert!(result.is_ok()); let tournament = result.unwrap(); assert_eq!(tournament.rankings.len(), config.agents.len()); assert_eq!(tournament.rankings[0].rank, 1); } #[test] fn test_rankings_ordered() { let mut arena = TradingArena::new(); let config = sample_tournament(); let result = arena.run(&config).unwrap(); // Check rankings are in order for (i, ranking) in result.rankings.iter().enumerate() { assert_eq!(ranking.rank, i + 1); } // Check total returns are descending for window in result.rankings.windows(2) { assert!(window[0].total_return >= window[1].total_return); } } #[test] fn test_sharpe_calculation() { let arena = TradingArena::new(); // Growth with some volatility - positive Sharpe // Alternating between 0.5% and 1.5% returns (avg 1%, std ~0.5%) let mut volatile_growth: Vec = vec![100.0]; for i in 0..100 { let ret = if i % 2 == 0 { 0.005 } else { 0.015 }; volatile_growth.push(volatile_growth.last().unwrap() * (1.0 + ret)); } let sharpe = arena.calculate_sharpe(&volatile_growth); assert!( sharpe > 0.0, "Sharpe should be positive for upward trend: {}", sharpe ); // Empty history let empty: Vec = vec![]; assert_eq!(arena.calculate_sharpe(&empty), 0.0); // Single element - not enough data let single: Vec = vec![100.0]; assert_eq!(arena.calculate_sharpe(&single), 0.0); } #[test] fn test_max_drawdown_calculation() { let arena = TradingArena::new(); // No drawdown case let up_only: Vec = vec![100.0, 110.0, 120.0, 130.0]; assert_eq!(arena.calculate_max_drawdown(&up_only), 0.0); // 10% drawdown let with_dd: Vec = vec![100.0, 110.0, 99.0, 105.0]; let dd = arena.calculate_max_drawdown(&with_dd); assert!((dd - 10.0).abs() < 0.5); // ~10% drawdown from 110 to 99 } #[test] fn test_win_rate_calculation() { let arena = TradingArena::new(); // All wins let all_up: Vec = vec![100.0, 101.0, 102.0, 103.0]; assert!((arena.calculate_win_rate(&all_up) - 100.0).abs() < 0.01); // All losses let all_down: Vec = vec![100.0, 99.0, 98.0, 97.0]; assert_eq!(arena.calculate_win_rate(&all_down), 0.0); // 50% wins let mixed: Vec = vec![100.0, 101.0, 100.0, 101.0, 100.0]; let wr = arena.calculate_win_rate(&mixed); assert!((wr - 50.0).abs() < 0.1); } #[test] fn test_market_stats() { let mut arena = TradingArena::new(); let config = sample_tournament(); let result = arena.run(&config).unwrap(); assert!(!result.market_stats.best_asset.is_empty()); assert!(!result.market_stats.worst_asset.is_empty()); } #[test] fn test_multiple_strategies() { let mut arena = TradingArena::new(); let config = sample_tournament(); let result = arena.run(&config).unwrap(); // Check all strategies are represented let strategies: Vec = result.rankings.iter().map(|r| r.strategy).collect(); assert!(strategies.contains(&StrategyType::Momentum)); assert!(strategies.contains(&StrategyType::MeanReversion)); assert!(strategies.contains(&StrategyType::TrendFollowing)); } #[test] fn test_short_tournament() { let mut arena = TradingArena::new(); let config = TournamentConfig { name: "Short Test".to_string(), num_steps: 10, assets: vec![AssetConfig { symbol: "TEST".to_string(), initial_price: 100.0, volatility: 0.01, drift: 0.0, }], agents: vec![AgentConfig { id: "test".to_string(), name: "Test Agent".to_string(), strategy: StrategyType::BuyAndHold, initial_capital: 100_000.0, parameters: AgentParameters::default(), }], transaction_cost_bps: 10.0, seed: Some(42), }; let result = arena.run(&config); assert!(result.is_ok()); } }