Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user