Initial commit
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
//! 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<TournamentResult, AlgoArenaError> {
|
||||
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<Box<dyn TradingAgent>> = 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<dyn TradingAgent>],
|
||||
configs: &[algoarena_shared::AgentConfig],
|
||||
) -> Vec<AgentResult> {
|
||||
let mut results: Vec<AgentResult> = 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<f64> = 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::<f64>() / returns.len() as f64;
|
||||
let variance: f64 =
|
||||
returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / 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<f64> = 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<f64>],
|
||||
) -> 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::<f64>() / 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<f64> = prices.windows(2).map(|w| (w[1] / w[0]).ln()).collect();
|
||||
|
||||
if returns.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
|
||||
let variance: f64 =
|
||||
returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / 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<f64> = 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<f64> = vec![];
|
||||
assert_eq!(arena.calculate_sharpe(&empty), 0.0);
|
||||
|
||||
// Single element - not enough data
|
||||
let single: Vec<f64> = 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<f64> = 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<f64> = 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<f64> = 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<f64> = vec![100.0, 99.0, 98.0, 97.0];
|
||||
assert_eq!(arena.calculate_win_rate(&all_down), 0.0);
|
||||
|
||||
// 50% wins
|
||||
let mixed: Vec<f64> = 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<StrategyType> = 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user