482 lines
15 KiB
Rust
482 lines
15 KiB
Rust
//! 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);
|
|
}
|
|
}
|