Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
+481
View File
@@ -0,0 +1,481 @@
//! Trading agents implementing various strategies.
use algoarena_shared::{
AgentConfig, AgentState, Execution, MarketState, Order, OrderSide, OrderType, StrategyType,
};
/// Trading agent trait.
pub trait TradingAgent: std::fmt::Debug {
/// Get agent ID.
fn id(&self) -> &str;
/// Get agent name.
fn name(&self) -> &str;
/// Get strategy type.
fn strategy_type(&self) -> StrategyType;
/// Observe market and update internal state.
fn observe(&mut self, state: &MarketState);
/// Decide on orders to place.
fn decide(&self, state: &MarketState) -> Vec<Order>;
/// Update after execution.
fn update(&mut self, executions: &[Execution]);
/// Get current agent state.
fn get_state(&self) -> AgentState;
/// Reset agent.
fn reset(&mut self);
}
/// Base agent implementation.
#[derive(Debug)]
pub struct BaseAgent {
/// Configuration.
config: AgentConfig,
/// Current cash.
cash: f64,
/// Holdings per asset.
holdings: Vec<f64>,
/// Portfolio value history.
pnl_history: Vec<f64>,
/// Number of trades.
trade_count: usize,
/// Last known prices.
last_prices: Vec<f64>,
/// Number of assets.
num_assets: usize,
}
impl BaseAgent {
/// Create a new base agent.
pub fn new(config: AgentConfig, num_assets: usize) -> Self {
Self {
cash: config.initial_capital,
config,
holdings: vec![0.0; num_assets],
pnl_history: vec![],
trade_count: 0,
last_prices: vec![0.0; num_assets],
num_assets,
}
}
/// Calculate portfolio value.
pub fn portfolio_value(&self, prices: &[f64]) -> f64 {
let holdings_value: f64 = self
.holdings
.iter()
.zip(prices.iter())
.map(|(h, p)| h * p)
.sum();
self.cash + holdings_value
}
}
impl TradingAgent for BaseAgent {
fn id(&self) -> &str {
&self.config.id
}
fn name(&self) -> &str {
&self.config.name
}
fn strategy_type(&self) -> StrategyType {
self.config.strategy
}
fn observe(&mut self, state: &MarketState) {
self.last_prices = state.prices.clone();
let value = self.portfolio_value(&state.prices);
self.pnl_history.push(value);
}
fn decide(&self, state: &MarketState) -> Vec<Order> {
match self.config.strategy {
StrategyType::BuyAndHold => self.buy_and_hold_strategy(state),
StrategyType::Momentum => self.momentum_strategy(state),
StrategyType::MeanReversion => self.mean_reversion_strategy(state),
StrategyType::TrendFollowing => self.trend_following_strategy(state),
StrategyType::Random => self.random_strategy(state),
StrategyType::Custom => vec![],
}
}
fn update(&mut self, executions: &[Execution]) {
for exec in executions {
if exec.is_filled {
self.trade_count += 1;
match exec.order.side {
OrderSide::Buy => {
self.holdings[exec.order.asset_idx] += exec.filled_quantity;
self.cash -= exec.filled_quantity * exec.fill_price + exec.transaction_cost;
}
OrderSide::Sell => {
self.holdings[exec.order.asset_idx] -= exec.filled_quantity;
self.cash += exec.filled_quantity * exec.fill_price - exec.transaction_cost;
}
}
}
}
}
fn get_state(&self) -> AgentState {
AgentState {
agent_id: self.config.id.clone(),
cash: self.cash,
holdings: self.holdings.clone(),
portfolio_value: self.portfolio_value(&self.last_prices),
pnl_history: self.pnl_history.clone(),
trade_count: self.trade_count,
}
}
fn reset(&mut self) {
self.cash = self.config.initial_capital;
self.holdings = vec![0.0; self.num_assets];
self.pnl_history.clear();
self.trade_count = 0;
}
}
impl BaseAgent {
/// Buy and hold strategy.
fn buy_and_hold_strategy(&self, state: &MarketState) -> Vec<Order> {
// Only buy on first step
if state.step != 1 {
return vec![];
}
let mut orders = Vec::new();
let allocation_per_asset = self.cash / state.prices.len() as f64;
for (i, &price) in state.prices.iter().enumerate() {
let quantity = (allocation_per_asset / price * 0.95).floor(); // 95% to leave some cash
if quantity > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Buy,
order_type: OrderType::Market,
quantity,
limit_price: None,
});
}
}
orders
}
/// Momentum strategy.
fn momentum_strategy(&self, state: &MarketState) -> Vec<Order> {
let lookback = self.config.parameters.lookback;
if state.step < lookback + 1 {
return vec![];
}
let mut orders = Vec::new();
let position_size = self.config.parameters.position_size;
for (i, history) in state.price_history.iter().enumerate() {
let current = history[state.step];
let past = history[state.step - lookback];
let momentum = (current - past) / past;
let threshold = self.config.parameters.momentum_threshold;
// Buy on positive momentum
if momentum > threshold && self.holdings[i] == 0.0 {
let allocation = self.cash * position_size;
let quantity = (allocation / current).floor();
if quantity > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Buy,
order_type: OrderType::Market,
quantity,
limit_price: None,
});
}
}
// Sell on negative momentum
else if momentum < -threshold && self.holdings[i] > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Sell,
order_type: OrderType::Market,
quantity: self.holdings[i],
limit_price: None,
});
}
}
orders
}
/// Mean reversion strategy.
fn mean_reversion_strategy(&self, state: &MarketState) -> Vec<Order> {
let lookback = self.config.parameters.lookback;
if state.step < lookback + 1 {
return vec![];
}
let mut orders = Vec::new();
let position_size = self.config.parameters.position_size;
let threshold = self.config.parameters.reversion_threshold;
for (i, history) in state.price_history.iter().enumerate() {
// Calculate mean and std dev
let window = &history[(state.step - lookback)..=state.step];
let mean: f64 = window.iter().sum::<f64>() / window.len() as f64;
let variance: f64 =
window.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / window.len() as f64;
let std_dev = variance.sqrt();
if std_dev < 0.001 {
continue;
}
let current = history[state.step];
let z_score = (current - mean) / std_dev;
// Buy when price is low (negative z-score)
if z_score < -threshold && self.holdings[i] == 0.0 {
let allocation = self.cash * position_size;
let quantity = (allocation / current).floor();
if quantity > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Buy,
order_type: OrderType::Market,
quantity,
limit_price: None,
});
}
}
// Sell when price is high (positive z-score)
else if z_score > threshold && self.holdings[i] > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Sell,
order_type: OrderType::Market,
quantity: self.holdings[i],
limit_price: None,
});
}
}
orders
}
/// Trend following strategy (MA crossover).
fn trend_following_strategy(&self, state: &MarketState) -> Vec<Order> {
let short_period = self.config.parameters.short_ma_period;
let long_period = self.config.parameters.long_ma_period;
if state.step < long_period + 1 {
return vec![];
}
let mut orders = Vec::new();
let position_size = self.config.parameters.position_size;
for (i, history) in state.price_history.iter().enumerate() {
// Calculate MAs
let short_ma: f64 = history[(state.step - short_period + 1)..=state.step]
.iter()
.sum::<f64>()
/ short_period as f64;
let long_ma: f64 = history[(state.step - long_period + 1)..=state.step]
.iter()
.sum::<f64>()
/ long_period as f64;
let prev_short_ma: f64 = history[(state.step - short_period)..state.step]
.iter()
.sum::<f64>()
/ short_period as f64;
let prev_long_ma: f64 = history[(state.step - long_period)..state.step]
.iter()
.sum::<f64>()
/ long_period as f64;
// Crossover detection
let golden_cross = prev_short_ma <= prev_long_ma && short_ma > long_ma;
let death_cross = prev_short_ma >= prev_long_ma && short_ma < long_ma;
if golden_cross && self.holdings[i] == 0.0 {
let allocation = self.cash * position_size;
let quantity = (allocation / state.prices[i]).floor();
if quantity > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Buy,
order_type: OrderType::Market,
quantity,
limit_price: None,
});
}
} else if death_cross && self.holdings[i] > 0.0 {
orders.push(Order {
agent_id: self.config.id.clone(),
asset_idx: i,
side: OrderSide::Sell,
order_type: OrderType::Market,
quantity: self.holdings[i],
limit_price: None,
});
}
}
orders
}
/// Random strategy (baseline).
fn random_strategy(&self, state: &MarketState) -> Vec<Order> {
// Use state step as pseudo-random source
let rand = ((state.step.wrapping_mul(1103515245).wrapping_add(12345)) >> 16) % 100;
if rand < 5 {
// 5% chance to trade
let asset_idx = state.step % self.num_assets;
if self.holdings[asset_idx] > 0.0 {
return vec![Order {
agent_id: self.config.id.clone(),
asset_idx,
side: OrderSide::Sell,
order_type: OrderType::Market,
quantity: self.holdings[asset_idx],
limit_price: None,
}];
} else {
let quantity = (self.cash * 0.1 / state.prices[asset_idx]).floor();
if quantity > 0.0 {
return vec![Order {
agent_id: self.config.id.clone(),
asset_idx,
side: OrderSide::Buy,
order_type: OrderType::Market,
quantity,
limit_price: None,
}];
}
}
}
vec![]
}
}
/// Create an agent from configuration.
pub fn create_agent(config: AgentConfig, num_assets: usize) -> Box<dyn TradingAgent> {
Box::new(BaseAgent::new(config, num_assets))
}
#[cfg(test)]
mod tests {
use super::*;
use algoarena_shared::{MarketIndicators, default_agents};
fn create_test_state() -> MarketState {
let mut history = vec![vec![100.0]; 3];
// Add 50 steps of history
for i in 0..50 {
for h in &mut history {
let change = 1.0 + 0.001 * (i as f64 - 25.0);
h.push(h.last().unwrap() * change);
}
}
MarketState {
step: 50,
prices: history.iter().map(|h| *h.last().unwrap()).collect(),
price_history: history,
volumes: vec![1_000_000.0; 3],
indicators: MarketIndicators::default(),
}
}
#[test]
fn test_base_agent_creation() {
let configs = default_agents();
let agent = BaseAgent::new(configs[0].clone(), 3);
assert_eq!(agent.id(), configs[0].id);
assert_eq!(agent.cash, configs[0].initial_capital);
}
#[test]
fn test_portfolio_value() {
let configs = default_agents();
let mut agent = BaseAgent::new(configs[0].clone(), 3);
agent.holdings = vec![10.0, 5.0, 20.0];
agent.cash = 1000.0;
let prices = vec![100.0, 200.0, 50.0];
let value = agent.portfolio_value(&prices);
// 10*100 + 5*200 + 20*50 + 1000 = 1000 + 1000 + 1000 + 1000 = 4000
assert!((value - 4000.0).abs() < 0.01);
}
#[test]
fn test_observe_updates_pnl() {
let configs = default_agents();
let mut agent = BaseAgent::new(configs[0].clone(), 3);
let state = create_test_state();
agent.observe(&state);
assert_eq!(agent.pnl_history.len(), 1);
}
#[test]
fn test_buy_and_hold() {
let configs = default_agents();
let buyhold = configs
.iter()
.find(|c| c.strategy == StrategyType::BuyAndHold)
.unwrap();
let agent = BaseAgent::new(buyhold.clone(), 3);
let mut state = create_test_state();
state.step = 1; // First step
let orders = agent.decide(&state);
assert!(!orders.is_empty()); // Should place orders on first step
}
#[test]
fn test_agent_reset() {
let configs = default_agents();
let mut agent = BaseAgent::new(configs[0].clone(), 3);
agent.cash = 50000.0;
agent.holdings = vec![10.0, 20.0, 30.0];
agent.trade_count = 5;
agent.reset();
assert_eq!(agent.cash, configs[0].initial_capital);
assert_eq!(agent.holdings, vec![0.0, 0.0, 0.0]);
assert_eq!(agent.trade_count, 0);
}
}
+430
View File
@@ -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());
}
}
+173
View File
@@ -0,0 +1,173 @@
//! AlgoArena - Strategy Backtesting Battleground.
//!
//! This demo showcases multi-agent trading simulation where different strategies
//! compete head-to-head in a simulated market environment.
pub mod agents;
pub mod arena;
pub mod market;
pub mod sample_data;
use algoarena_shared::{TournamentConfig, TournamentResult};
use thiserror::Error;
use arena::TradingArena;
/// Errors that can occur during tournament simulation.
#[derive(Debug, Error)]
pub enum AlgoArenaError {
/// No agents provided.
#[error("No agents provided for tournament")]
NoAgents,
/// No assets provided.
#[error("No assets provided for simulation")]
NoAssets,
/// Invalid configuration.
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
/// Simulation failed.
#[error("Simulation failed: {0}")]
SimulationFailed(String),
}
/// Main AlgoArena system.
#[derive(Debug)]
pub struct AlgoArena {
/// Trading arena.
arena: TradingArena,
}
impl Default for AlgoArena {
fn default() -> Self {
Self::new()
}
}
impl AlgoArena {
/// Create a new AlgoArena.
#[must_use]
pub fn new() -> Self {
Self {
arena: TradingArena::new(),
}
}
/// Run a tournament.
pub fn run_tournament(
&mut self,
config: &TournamentConfig,
) -> Result<TournamentResult, AlgoArenaError> {
// Validate config
if config.agents.is_empty() {
return Err(AlgoArenaError::NoAgents);
}
if config.assets.is_empty() {
return Err(AlgoArenaError::NoAssets);
}
if config.num_steps == 0 {
return Err(AlgoArenaError::InvalidConfig(
"num_steps must be > 0".to_string(),
));
}
// Run simulation
self.arena.run(config)
}
/// Get the trading arena.
#[must_use]
pub fn arena(&self) -> &TradingArena {
&self.arena
}
}
/// Run the full demo.
pub fn run_demo() -> Result<TournamentResult, AlgoArenaError> {
let mut arena = AlgoArena::new();
let config = sample_data::create_sample_tournament();
arena.run_tournament(&config)
}
#[cfg(test)]
mod tests {
use super::*;
use algoarena_shared::{
AgentConfig, AgentParameters, AssetConfig, StrategyType, sample_tournament,
};
#[test]
fn test_algoarena_creation() {
let arena = AlgoArena::new();
assert!(std::mem::size_of_val(&arena) > 0);
}
#[test]
fn test_run_tournament() {
let mut arena = AlgoArena::new();
let config = sample_tournament();
let result = arena.run_tournament(&config);
assert!(result.is_ok());
let tournament = result.unwrap();
assert!(!tournament.rankings.is_empty());
assert_eq!(tournament.rankings[0].rank, 1);
}
#[test]
fn test_no_agents_error() {
let mut arena = AlgoArena::new();
let config = TournamentConfig {
name: "Empty".to_string(),
num_steps: 100,
assets: vec![AssetConfig {
symbol: "TEST".to_string(),
initial_price: 100.0,
volatility: 0.01,
drift: 0.0,
}],
agents: vec![],
transaction_cost_bps: 10.0,
seed: Some(42),
};
let result = arena.run_tournament(&config);
assert!(matches!(result, Err(AlgoArenaError::NoAgents)));
}
#[test]
fn test_no_assets_error() {
let mut arena = AlgoArena::new();
let config = TournamentConfig {
name: "Empty".to_string(),
num_steps: 100,
assets: vec![],
agents: vec![AgentConfig {
id: "test".to_string(),
name: "Test".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_tournament(&config);
assert!(matches!(result, Err(AlgoArenaError::NoAssets)));
}
#[test]
fn test_run_demo() {
let result = run_demo();
assert!(result.is_ok());
let tournament = result.unwrap();
assert!(tournament.rankings.len() >= 3);
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Market simulation.
//!
//! Generates price paths and handles order execution.
use algoarena_shared::{
AssetConfig, Execution, MarketIndicators, MarketState, Order, OrderSide, OrderType,
};
/// Market simulator.
#[derive(Debug)]
pub struct MarketSimulator {
/// Asset configurations.
assets: Vec<AssetConfig>,
/// Current prices.
prices: Vec<f64>,
/// Price history.
price_history: Vec<Vec<f64>>,
/// Current step.
step: usize,
/// RNG state.
rng: SimpleRng,
/// Transaction cost (basis points).
transaction_cost_bps: f64,
}
impl MarketSimulator {
/// Create a new market simulator.
#[must_use]
pub fn new(assets: Vec<AssetConfig>, transaction_cost_bps: f64, seed: u64) -> Self {
let prices: Vec<f64> = assets.iter().map(|a| a.initial_price).collect();
let price_history: Vec<Vec<f64>> = assets.iter().map(|a| vec![a.initial_price]).collect();
Self {
assets,
prices,
price_history,
step: 0,
rng: SimpleRng::new(seed),
transaction_cost_bps,
}
}
/// Advance one time step.
pub fn step(&mut self) {
self.step += 1;
for (i, asset) in self.assets.iter().enumerate() {
// Geometric Brownian Motion
let z = self.rng.normal();
let return_val = asset.drift + asset.volatility * z;
self.prices[i] *= 1.0 + return_val;
self.price_history[i].push(self.prices[i]);
}
}
/// Get current market state.
#[must_use]
pub fn get_state(&self) -> MarketState {
let volumes: Vec<f64> = self.prices.iter().map(|p| p * 1_000_000.0).collect();
// Calculate market indicators
let market_return = if self.step > 0 {
let initial: f64 = self.price_history.iter().map(|h| h[0]).sum();
let current: f64 = self.prices.iter().sum();
(current - initial) / initial * 100.0
} else {
0.0
};
let volatility = self.calculate_realized_volatility();
let trend = self.calculate_trend();
MarketState {
step: self.step,
prices: self.prices.clone(),
price_history: self.price_history.clone(),
volumes,
indicators: MarketIndicators {
market_return,
volatility,
trend,
},
}
}
/// Execute an order.
#[must_use]
pub fn execute_order(&mut self, order: &Order) -> Execution {
let price = self.prices[order.asset_idx];
// Slippage based on order size (simplified)
let slippage_pct = 0.0005 * order.quantity / 100.0;
let slippage = price * slippage_pct;
let fill_price = match order.side {
OrderSide::Buy => price + slippage,
OrderSide::Sell => price - slippage,
};
// Check limit orders
let is_filled = match order.order_type {
OrderType::Market => true,
OrderType::Limit => match order.side {
OrderSide::Buy => order.limit_price.is_none_or(|limit| fill_price <= limit),
OrderSide::Sell => order.limit_price.is_none_or(|limit| fill_price >= limit),
},
};
let filled_quantity = if is_filled { order.quantity } else { 0.0 };
let transaction_cost = filled_quantity * fill_price * self.transaction_cost_bps / 10_000.0;
Execution {
order: order.clone(),
filled_quantity,
fill_price,
transaction_cost,
slippage,
is_filled,
}
}
/// Get current prices.
#[must_use]
pub fn prices(&self) -> &[f64] {
&self.prices
}
/// Get price history.
#[must_use]
pub fn price_history(&self) -> &[Vec<f64>] {
&self.price_history
}
/// Calculate realized volatility.
fn calculate_realized_volatility(&self) -> f64 {
if self.step < 2 {
return 0.0;
}
let lookback = self.step.min(20);
let mut returns = Vec::new();
for i in (self.step - lookback + 1)..=self.step {
let ret = (self.price_history[0][i] / self.price_history[0][i - 1]).ln();
returns.push(ret);
}
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 %
}
/// Calculate market trend.
fn calculate_trend(&self) -> f64 {
if self.step < 10 {
return 0.0;
}
// Short MA vs Long MA
let short_period = 10.min(self.step);
let long_period = 50.min(self.step);
let short_ma: f64 = self.price_history[0][(self.step - short_period + 1)..=self.step]
.iter()
.sum::<f64>()
/ short_period as f64;
let long_ma: f64 = self.price_history[0][(self.step - long_period + 1)..=self.step]
.iter()
.sum::<f64>()
/ long_period as f64;
(short_ma - long_ma) / long_ma * 100.0
}
}
/// Simple pseudo-random number generator.
struct SimpleRng {
state: u64,
}
impl std::fmt::Debug for SimpleRng {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SimpleRng").finish()
}
}
impl SimpleRng {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.state
}
fn uniform(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
fn normal(&mut self) -> f64 {
let u1 = self.uniform() + 1e-10;
let u2 = self.uniform();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
#[cfg(test)]
mod tests {
use super::*;
use algoarena_shared::default_assets;
#[test]
fn test_market_creation() {
let assets = default_assets();
let market = MarketSimulator::new(assets.clone(), 10.0, 42);
assert_eq!(market.prices().len(), assets.len());
}
#[test]
fn test_market_step() {
let assets = default_assets();
let mut market = MarketSimulator::new(assets, 10.0, 42);
let initial_price = market.prices()[0];
market.step();
// Price should have changed
assert_ne!(market.prices()[0], initial_price);
}
#[test]
fn test_get_state() {
let assets = default_assets();
let mut market = MarketSimulator::new(assets, 10.0, 42);
for _ in 0..10 {
market.step();
}
let state = market.get_state();
assert_eq!(state.step, 10);
assert_eq!(state.price_history[0].len(), 11); // Initial + 10 steps
}
#[test]
fn test_execute_order() {
let assets = default_assets();
let mut market = MarketSimulator::new(assets, 10.0, 42);
let order = Order {
agent_id: "test".to_string(),
asset_idx: 0,
side: OrderSide::Buy,
order_type: OrderType::Market,
quantity: 10.0,
limit_price: None,
};
let execution = market.execute_order(&order);
assert!(execution.is_filled);
assert_eq!(execution.filled_quantity, 10.0);
}
}
+342
View File
@@ -0,0 +1,342 @@
//! Sample data for AlgoArena demos.
use algoarena_shared::{AgentConfig, AgentParameters, AssetConfig, StrategyType, TournamentConfig};
/// Create a sample tournament configuration.
#[must_use]
pub fn create_sample_tournament() -> TournamentConfig {
TournamentConfig {
name: "Strategy Showdown 2026".to_string(),
num_steps: 252, // One trading year
assets: create_diverse_assets(),
agents: create_competing_agents(),
transaction_cost_bps: 10.0,
seed: Some(42),
}
}
/// Create a diverse set of assets for trading.
#[must_use]
pub fn create_diverse_assets() -> Vec<AssetConfig> {
vec![
AssetConfig {
symbol: "SPY".to_string(),
initial_price: 500.0,
volatility: 0.012, // ~19% annual
drift: 0.0003, // ~7.5% annual
},
AssetConfig {
symbol: "QQQ".to_string(),
initial_price: 450.0,
volatility: 0.015, // ~24% annual
drift: 0.0004, // ~10% annual
},
AssetConfig {
symbol: "TLT".to_string(),
initial_price: 95.0,
volatility: 0.010, // ~16% annual
drift: 0.0001, // ~2.5% annual
},
AssetConfig {
symbol: "GLD".to_string(),
initial_price: 200.0,
volatility: 0.008, // ~13% annual
drift: 0.0002, // ~5% annual
},
]
}
/// Create a set of competing agents with different strategies.
#[must_use]
pub fn create_competing_agents() -> Vec<AgentConfig> {
vec![
// Momentum Strategies
AgentConfig {
id: "momentum_fast".to_string(),
name: "Fast Momentum".to_string(),
strategy: StrategyType::Momentum,
initial_capital: 100_000.0,
parameters: AgentParameters {
lookback: 10,
momentum_threshold: 0.015,
position_size: 0.15,
..Default::default()
},
},
AgentConfig {
id: "momentum_slow".to_string(),
name: "Slow Momentum".to_string(),
strategy: StrategyType::Momentum,
initial_capital: 100_000.0,
parameters: AgentParameters {
lookback: 30,
momentum_threshold: 0.03,
position_size: 0.2,
..Default::default()
},
},
// Mean Reversion Strategies
AgentConfig {
id: "reversion_tight".to_string(),
name: "Tight Reverter".to_string(),
strategy: StrategyType::MeanReversion,
initial_capital: 100_000.0,
parameters: AgentParameters {
lookback: 15,
reversion_threshold: 1.5,
position_size: 0.1,
..Default::default()
},
},
AgentConfig {
id: "reversion_wide".to_string(),
name: "Wide Reverter".to_string(),
strategy: StrategyType::MeanReversion,
initial_capital: 100_000.0,
parameters: AgentParameters {
lookback: 30,
reversion_threshold: 2.5,
position_size: 0.15,
..Default::default()
},
},
// Trend Following Strategies
AgentConfig {
id: "trend_classic".to_string(),
name: "Classic Trend".to_string(),
strategy: StrategyType::TrendFollowing,
initial_capital: 100_000.0,
parameters: AgentParameters {
short_ma_period: 10,
long_ma_period: 50,
position_size: 0.15,
..Default::default()
},
},
AgentConfig {
id: "trend_fast".to_string(),
name: "Fast Trend".to_string(),
strategy: StrategyType::TrendFollowing,
initial_capital: 100_000.0,
parameters: AgentParameters {
short_ma_period: 5,
long_ma_period: 20,
position_size: 0.2,
..Default::default()
},
},
// Passive Strategy
AgentConfig {
id: "buyhold".to_string(),
name: "Buy & Hold".to_string(),
strategy: StrategyType::BuyAndHold,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
// Random Baseline
AgentConfig {
id: "random".to_string(),
name: "Random Walker".to_string(),
strategy: StrategyType::Random,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
]
}
/// Create a quick demo tournament (fewer steps for testing).
#[must_use]
pub fn create_quick_tournament() -> TournamentConfig {
TournamentConfig {
name: "Quick Demo".to_string(),
num_steps: 50, // ~2 months
assets: vec![
AssetConfig {
symbol: "SPY".to_string(),
initial_price: 500.0,
volatility: 0.012,
drift: 0.0003,
},
AssetConfig {
symbol: "QQQ".to_string(),
initial_price: 450.0,
volatility: 0.015,
drift: 0.0004,
},
],
agents: vec![
AgentConfig {
id: "momentum".to_string(),
name: "Momentum".to_string(),
strategy: StrategyType::Momentum,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
AgentConfig {
id: "reversion".to_string(),
name: "Mean Reversion".to_string(),
strategy: StrategyType::MeanReversion,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
AgentConfig {
id: "buyhold".to_string(),
name: "Buy & Hold".to_string(),
strategy: StrategyType::BuyAndHold,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
],
transaction_cost_bps: 10.0,
seed: Some(42),
}
}
/// Create a high-volatility tournament.
#[must_use]
pub fn create_volatile_tournament() -> TournamentConfig {
TournamentConfig {
name: "Volatility Challenge".to_string(),
num_steps: 100,
assets: vec![
AssetConfig {
symbol: "CRYPTO".to_string(),
initial_price: 50000.0,
volatility: 0.04, // ~63% annual
drift: 0.001, // ~25% annual
},
AssetConfig {
symbol: "MEME".to_string(),
initial_price: 10.0,
volatility: 0.08, // ~127% annual
drift: 0.0, // No drift
},
],
agents: create_competing_agents(),
transaction_cost_bps: 25.0, // Higher costs
seed: Some(123),
}
}
/// Create a low-volatility tournament.
#[must_use]
pub fn create_stable_tournament() -> TournamentConfig {
TournamentConfig {
name: "Stability Test".to_string(),
num_steps: 200,
assets: vec![
AssetConfig {
symbol: "BOND1".to_string(),
initial_price: 100.0,
volatility: 0.003, // ~5% annual
drift: 0.0001, // ~2.5% annual
},
AssetConfig {
symbol: "BOND2".to_string(),
initial_price: 100.0,
volatility: 0.004, // ~6% annual
drift: 0.00015, // ~3.8% annual
},
],
agents: vec![
AgentConfig {
id: "reversion".to_string(),
name: "Mean Reversion".to_string(),
strategy: StrategyType::MeanReversion,
initial_capital: 100_000.0,
parameters: AgentParameters {
reversion_threshold: 1.0, // Tighter for low vol
..Default::default()
},
},
AgentConfig {
id: "trend".to_string(),
name: "Trend Following".to_string(),
strategy: StrategyType::TrendFollowing,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
AgentConfig {
id: "buyhold".to_string(),
name: "Buy & Hold".to_string(),
strategy: StrategyType::BuyAndHold,
initial_capital: 100_000.0,
parameters: AgentParameters::default(),
},
],
transaction_cost_bps: 5.0, // Lower costs for bonds
seed: Some(456),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sample_tournament() {
let config = create_sample_tournament();
assert_eq!(config.num_steps, 252);
assert!(!config.assets.is_empty());
assert!(!config.agents.is_empty());
}
#[test]
fn test_diverse_assets() {
let assets = create_diverse_assets();
assert_eq!(assets.len(), 4);
// Check all have positive prices
for asset in &assets {
assert!(asset.initial_price > 0.0);
assert!(asset.volatility > 0.0);
}
}
#[test]
fn test_competing_agents() {
let agents = create_competing_agents();
assert_eq!(agents.len(), 8);
// Check all have positive capital
for agent in &agents {
assert!(agent.initial_capital > 0.0);
}
// Check unique IDs
let ids: Vec<_> = agents.iter().map(|a| &a.id).collect();
let unique_ids: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(ids.len(), unique_ids.len());
}
#[test]
fn test_quick_tournament() {
let config = create_quick_tournament();
assert_eq!(config.num_steps, 50);
assert_eq!(config.agents.len(), 3);
}
#[test]
fn test_volatile_tournament() {
let config = create_volatile_tournament();
assert!(config.assets[0].volatility > 0.03); // High volatility
}
#[test]
fn test_stable_tournament() {
let config = create_stable_tournament();
assert!(config.assets[0].volatility < 0.01); // Low volatility
}
#[test]
fn test_strategy_diversity() {
let agents = create_competing_agents();
let strategies: std::collections::HashSet<_> = agents.iter().map(|a| a.strategy).collect();
assert!(strategies.contains(&StrategyType::Momentum));
assert!(strategies.contains(&StrategyType::MeanReversion));
assert!(strategies.contains(&StrategyType::TrendFollowing));
assert!(strategies.contains(&StrategyType::BuyAndHold));
assert!(strategies.contains(&StrategyType::Random));
}
}