Files
rustytorch/demos/rtx-algoarena-demo/src/arena.rs
T
2026-03-04 00:08:42 +00:00

431 lines
13 KiB
Rust

//! 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());
}
}