diff --git a/migrations/041_create_strategy_configs_table.sql b/migrations/041_create_strategy_configs_table.sql new file mode 100644 index 000000000..cfdb2f157 --- /dev/null +++ b/migrations/041_create_strategy_configs_table.sql @@ -0,0 +1,39 @@ +-- Create strategy_configs table for trading agent service +-- Manages strategy registration, configuration, and lifecycle + +CREATE TABLE IF NOT EXISTS strategy_configs ( + strategy_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + strategy_name TEXT NOT NULL, + strategy_type TEXT NOT NULL CHECK (strategy_type IN ('EqualWeight', 'RiskParity', 'MLOptimized', 'MeanVariance', 'Momentum', 'MeanReversion')), + parameters JSONB NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'Active' CHECK (status IN ('Active', 'Paused', 'Stopped')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(strategy_name) +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_strategy_configs_type ON strategy_configs(strategy_type); +CREATE INDEX IF NOT EXISTS idx_strategy_configs_status ON strategy_configs(status); +CREATE INDEX IF NOT EXISTS idx_strategy_configs_created ON strategy_configs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_strategy_configs_parameters ON strategy_configs USING GIN (parameters); + +-- Updated timestamp trigger +CREATE OR REPLACE FUNCTION update_strategy_configs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_strategy_configs_updated_at + BEFORE UPDATE ON strategy_configs + FOR EACH ROW + EXECUTE FUNCTION update_strategy_configs_updated_at(); + +-- Comments +COMMENT ON TABLE strategy_configs IS 'Strategy configuration and lifecycle management for trading agent'; +COMMENT ON COLUMN strategy_configs.strategy_type IS 'Type of allocation strategy (EqualWeight, RiskParity, MLOptimized, MeanVariance, Momentum, MeanReversion)'; +COMMENT ON COLUMN strategy_configs.parameters IS 'JSONB configuration parameters specific to strategy type'; +COMMENT ON COLUMN strategy_configs.status IS 'Strategy status (Active, Paused, Stopped)'; diff --git a/services/trading_agent_service/.sqlx/query-2a88bd43a5df2a9f9c5bbcfadf6c869f0d273f8063411e49f6691c4d20655a14.json b/services/trading_agent_service/.sqlx/query-2a88bd43a5df2a9f9c5bbcfadf6c869f0d273f8063411e49f6691c4d20655a14.json new file mode 100644 index 000000000..7e820fcad --- /dev/null +++ b/services/trading_agent_service/.sqlx/query-2a88bd43a5df2a9f9c5bbcfadf6c869f0d273f8063411e49f6691c4d20655a14.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n strategy_id,\n strategy_name,\n strategy_type,\n parameters,\n status,\n created_at,\n updated_at\n FROM strategy_configs\n WHERE strategy_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "strategy_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "strategy_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "strategy_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2a88bd43a5df2a9f9c5bbcfadf6c869f0d273f8063411e49f6691c4d20655a14" +} diff --git a/services/trading_agent_service/.sqlx/query-91de6a60159626c5cb3ee1c3d8c66c0e2ec33c32bdc6510c235931b3cd509f2d.json b/services/trading_agent_service/.sqlx/query-91de6a60159626c5cb3ee1c3d8c66c0e2ec33c32bdc6510c235931b3cd509f2d.json new file mode 100644 index 000000000..5909bb2c8 --- /dev/null +++ b/services/trading_agent_service/.sqlx/query-91de6a60159626c5cb3ee1c3d8c66c0e2ec33c32bdc6510c235931b3cd509f2d.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO agent_orders (\n order_id, allocation_id, symbol, side, quantity, price,\n order_type, status, time_in_force, filled_quantity,\n client_order_id, created_at, metadata\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Numeric", + "Numeric", + "Text", + "Text", + "Text", + "Numeric", + "Text", + "Timestamptz", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "91de6a60159626c5cb3ee1c3d8c66c0e2ec33c32bdc6510c235931b3cd509f2d" +} diff --git a/services/trading_agent_service/.sqlx/query-e88a192d1ad771838a473e16a677146f3fef9346ea88d294a97352282384fd5c.json b/services/trading_agent_service/.sqlx/query-e88a192d1ad771838a473e16a677146f3fef9346ea88d294a97352282384fd5c.json new file mode 100644 index 000000000..346506d05 --- /dev/null +++ b/services/trading_agent_service/.sqlx/query-e88a192d1ad771838a473e16a677146f3fef9346ea88d294a97352282384fd5c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE strategy_configs\n SET status = $1\n WHERE strategy_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e88a192d1ad771838a473e16a677146f3fef9346ea88d294a97352282384fd5c" +} diff --git a/services/trading_agent_service/.sqlx/query-f218fc76780ca39d146674eae4bab7707bedc41d641545d8ea96df69ee5a36e9.json b/services/trading_agent_service/.sqlx/query-f218fc76780ca39d146674eae4bab7707bedc41d641545d8ea96df69ee5a36e9.json new file mode 100644 index 000000000..67a7a2c4f --- /dev/null +++ b/services/trading_agent_service/.sqlx/query-f218fc76780ca39d146674eae4bab7707bedc41d641545d8ea96df69ee5a36e9.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n strategy_id,\n strategy_name,\n strategy_type,\n parameters,\n status,\n created_at,\n updated_at\n FROM strategy_configs\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "strategy_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "strategy_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "strategy_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "f218fc76780ca39d146674eae4bab7707bedc41d641545d8ea96df69ee5a36e9" +} diff --git a/services/trading_agent_service/.sqlx/query-f63e4cd5aff2bf33961383e53c5bfb559b5a3fcf75ee897e38ca024bdf1ce745.json b/services/trading_agent_service/.sqlx/query-f63e4cd5aff2bf33961383e53c5bfb559b5a3fcf75ee897e38ca024bdf1ce745.json new file mode 100644 index 000000000..3d240e86d --- /dev/null +++ b/services/trading_agent_service/.sqlx/query-f63e4cd5aff2bf33961383e53c5bfb559b5a3fcf75ee897e38ca024bdf1ce745.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n strategy_id,\n strategy_name,\n strategy_type,\n parameters,\n status,\n created_at,\n updated_at\n FROM strategy_configs\n WHERE status = 'Active'\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "strategy_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "strategy_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "strategy_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "parameters", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "f63e4cd5aff2bf33961383e53c5bfb559b5a3fcf75ee897e38ca024bdf1ce745" +} diff --git a/services/trading_agent_service/.sqlx/query-f6e6de13c5202107e4d26e19af7a80dd5186eac2ac77e0a6cb7edd4ea0e792d3.json b/services/trading_agent_service/.sqlx/query-f6e6de13c5202107e4d26e19af7a80dd5186eac2ac77e0a6cb7edd4ea0e792d3.json new file mode 100644 index 000000000..db26ad734 --- /dev/null +++ b/services/trading_agent_service/.sqlx/query-f6e6de13c5202107e4d26e19af7a80dd5186eac2ac77e0a6cb7edd4ea0e792d3.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO strategy_configs (strategy_name, strategy_type, parameters, status)\n VALUES ($1, $2, $3, $4)\n RETURNING strategy_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "strategy_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f6e6de13c5202107e4d26e19af7a80dd5186eac2ac77e0a6cb7edd4ea0e792d3" +} diff --git a/services/trading_agent_service/src/lib.rs b/services/trading_agent_service/src/lib.rs index f34a58e22..6d6333427 100644 --- a/services/trading_agent_service/src/lib.rs +++ b/services/trading_agent_service/src/lib.rs @@ -11,8 +11,8 @@ pub mod proto { pub mod service; pub mod universe; +pub mod orders; +pub mod strategies; +pub mod monitoring; // pub mod assets; // TODO: Implement in Phase 2 // pub mod allocation; // TODO: Implement in Phase 3 -// pub mod orders; // TODO: Implement in Phase 4 -// pub mod strategies; // TODO: Implement in Phase 5 -// pub mod monitoring; // TODO: Implement in Phase 6 diff --git a/services/trading_agent_service/src/strategies.rs b/services/trading_agent_service/src/strategies.rs index 879c8215c..80cb5aa73 100644 --- a/services/trading_agent_service/src/strategies.rs +++ b/services/trading_agent_service/src/strategies.rs @@ -2,4 +2,455 @@ //! //! Manages multiple trading strategies and their lifecycle. -// Stub implementation - to be filled in future agents +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; +use thiserror::Error; +use tracing::{debug, error, info, instrument, warn}; + +/// Strategy coordinator for managing strategy lifecycle +#[derive(Debug, Clone)] +pub struct StrategyCoordinator { + pool: PgPool, +} + +/// Strategy types supported by the trading agent +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text")] +pub enum StrategyType { + EqualWeight, + RiskParity, + MLOptimized, + MeanVariance, + Momentum, + MeanReversion, +} + +impl std::fmt::Display for StrategyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StrategyType::EqualWeight => write!(f, "EqualWeight"), + StrategyType::RiskParity => write!(f, "RiskParity"), + StrategyType::MLOptimized => write!(f, "MLOptimized"), + StrategyType::MeanVariance => write!(f, "MeanVariance"), + StrategyType::Momentum => write!(f, "Momentum"), + StrategyType::MeanReversion => write!(f, "MeanReversion"), + } + } +} + +impl std::str::FromStr for StrategyType { + type Err = StrategyError; + + fn from_str(s: &str) -> Result { + match s { + "EqualWeight" => Ok(StrategyType::EqualWeight), + "RiskParity" => Ok(StrategyType::RiskParity), + "MLOptimized" => Ok(StrategyType::MLOptimized), + "MeanVariance" => Ok(StrategyType::MeanVariance), + "Momentum" => Ok(StrategyType::Momentum), + "MeanReversion" => Ok(StrategyType::MeanReversion), + _ => Err(StrategyError::InvalidStrategyType(s.to_string())), + } + } +} + +/// Strategy status +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "text")] +pub enum StrategyStatus { + Active, + Paused, + Stopped, +} + +impl std::fmt::Display for StrategyStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StrategyStatus::Active => write!(f, "Active"), + StrategyStatus::Paused => write!(f, "Paused"), + StrategyStatus::Stopped => write!(f, "Stopped"), + } + } +} + +impl std::str::FromStr for StrategyStatus { + type Err = StrategyError; + + fn from_str(s: &str) -> Result { + match s { + "Active" => Ok(StrategyStatus::Active), + "Paused" => Ok(StrategyStatus::Paused), + "Stopped" => Ok(StrategyStatus::Stopped), + _ => Err(StrategyError::InvalidStatus(s.to_string())), + } + } +} + +/// Strategy configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StrategyConfig { + pub strategy_id: String, + pub strategy_name: String, + pub strategy_type: StrategyType, + pub parameters: HashMap, + pub status: StrategyStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Strategy-related errors +#[derive(Debug, Error)] +pub enum StrategyError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Strategy not found: {0}")] + NotFound(String), + + #[error("Invalid strategy type: {0}")] + InvalidStrategyType(String), + + #[error("Invalid status: {0}")] + InvalidStatus(String), + + #[error("Strategy name already exists: {0}")] + DuplicateName(String), + + #[error("JSON serialization error: {0}")] + JsonError(#[from] serde_json::Error), +} + +impl StrategyCoordinator { + /// Create a new strategy coordinator + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Register a new strategy + #[instrument(skip(self, config), fields(strategy_name = %config.strategy_name, strategy_type = ?config.strategy_type))] + pub async fn register_strategy(&self, config: StrategyConfig) -> Result { + info!( + strategy_name = %config.strategy_name, + strategy_type = ?config.strategy_type, + "Registering new strategy" + ); + + // Serialize parameters to JSONB + let parameters_json = serde_json::to_value(&config.parameters)?; + + // Insert strategy into database + let result = sqlx::query!( + r#" + INSERT INTO strategy_configs (strategy_name, strategy_type, parameters, status) + VALUES ($1, $2, $3, $4) + RETURNING strategy_id + "#, + config.strategy_name, + config.strategy_type.to_string(), + parameters_json, + config.status.to_string() + ) + .fetch_one(&self.pool) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.is_unique_violation() { + error!( + strategy_name = %config.strategy_name, + "Strategy name already exists" + ); + return StrategyError::DuplicateName(config.strategy_name.clone()); + } + } + error!(error = ?e, "Failed to register strategy"); + StrategyError::Database(e) + })?; + + let strategy_id = result.strategy_id.to_string(); + + info!( + strategy_id = %strategy_id, + strategy_name = %config.strategy_name, + "Strategy registered successfully" + ); + + Ok(strategy_id) + } + + /// List all strategies + #[instrument(skip(self))] + pub async fn list_strategies(&self) -> Result, StrategyError> { + debug!("Listing all strategies"); + + let rows = sqlx::query!( + r#" + SELECT + strategy_id, + strategy_name, + strategy_type, + parameters, + status, + created_at, + updated_at + FROM strategy_configs + ORDER BY created_at DESC + "# + ) + .fetch_all(&self.pool) + .await + .map_err(|e| { + error!(error = ?e, "Failed to list strategies"); + StrategyError::Database(e) + })?; + + let strategies: Vec = rows + .into_iter() + .map(|row| { + let parameters: HashMap = + serde_json::from_value(row.parameters).unwrap_or_default(); + + let strategy_type = row.strategy_type.parse().unwrap_or_else(|e| { + warn!( + error = ?e, + strategy_type = %row.strategy_type, + "Failed to parse strategy type, defaulting to EqualWeight" + ); + StrategyType::EqualWeight + }); + + let status = row.status.parse().unwrap_or_else(|e| { + warn!( + error = ?e, + status = %row.status, + "Failed to parse status, defaulting to Active" + ); + StrategyStatus::Active + }); + + StrategyConfig { + strategy_id: row.strategy_id.to_string(), + strategy_name: row.strategy_name, + strategy_type, + parameters, + status, + created_at: row.created_at, + updated_at: row.updated_at, + } + }) + .collect(); + + debug!(count = strategies.len(), "Retrieved strategies"); + + Ok(strategies) + } + + /// Update strategy status + #[instrument(skip(self), fields(strategy_id = %strategy_id, new_status = ?status))] + pub async fn update_status( + &self, + strategy_id: &str, + status: StrategyStatus, + ) -> Result<(), StrategyError> { + info!( + strategy_id = %strategy_id, + new_status = ?status, + "Updating strategy status" + ); + + // Parse UUID + let uuid = uuid::Uuid::parse_str(strategy_id).map_err(|e| { + error!( + strategy_id = %strategy_id, + error = ?e, + "Invalid strategy ID format" + ); + StrategyError::NotFound(strategy_id.to_string()) + })?; + + let result = sqlx::query!( + r#" + UPDATE strategy_configs + SET status = $1 + WHERE strategy_id = $2 + "#, + status.to_string(), + uuid + ) + .execute(&self.pool) + .await + .map_err(|e| { + error!(error = ?e, "Failed to update strategy status"); + StrategyError::Database(e) + })?; + + if result.rows_affected() == 0 { + error!( + strategy_id = %strategy_id, + "Strategy not found for status update" + ); + return Err(StrategyError::NotFound(strategy_id.to_string())); + } + + info!( + strategy_id = %strategy_id, + new_status = ?status, + "Strategy status updated successfully" + ); + + Ok(()) + } + + /// Get active strategies only + #[instrument(skip(self))] + pub async fn get_active_strategies(&self) -> Result, StrategyError> { + debug!("Fetching active strategies"); + + let rows = sqlx::query!( + r#" + SELECT + strategy_id, + strategy_name, + strategy_type, + parameters, + status, + created_at, + updated_at + FROM strategy_configs + WHERE status = 'Active' + ORDER BY created_at DESC + "# + ) + .fetch_all(&self.pool) + .await + .map_err(|e| { + error!(error = ?e, "Failed to fetch active strategies"); + StrategyError::Database(e) + })?; + + let strategies: Vec = rows + .into_iter() + .map(|row| { + let parameters: HashMap = + serde_json::from_value(row.parameters).unwrap_or_default(); + + let strategy_type = row.strategy_type.parse().unwrap_or(StrategyType::EqualWeight); + let status = row.status.parse().unwrap_or(StrategyStatus::Active); + + StrategyConfig { + strategy_id: row.strategy_id.to_string(), + strategy_name: row.strategy_name, + strategy_type, + parameters, + status, + created_at: row.created_at, + updated_at: row.updated_at, + } + }) + .collect(); + + debug!(count = strategies.len(), "Retrieved active strategies"); + + Ok(strategies) + } + + /// Get strategy by ID + #[instrument(skip(self), fields(strategy_id = %strategy_id))] + pub async fn get_strategy(&self, strategy_id: &str) -> Result { + debug!(strategy_id = %strategy_id, "Fetching strategy by ID"); + + // Parse UUID + let uuid = uuid::Uuid::parse_str(strategy_id).map_err(|e| { + error!( + strategy_id = %strategy_id, + error = ?e, + "Invalid strategy ID format" + ); + StrategyError::NotFound(strategy_id.to_string()) + })?; + + let row = sqlx::query!( + r#" + SELECT + strategy_id, + strategy_name, + strategy_type, + parameters, + status, + created_at, + updated_at + FROM strategy_configs + WHERE strategy_id = $1 + "#, + uuid + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| { + error!(error = ?e, "Failed to fetch strategy"); + StrategyError::Database(e) + })?; + + match row { + Some(row) => { + let parameters: HashMap = + serde_json::from_value(row.parameters).unwrap_or_default(); + + let strategy_type = row.strategy_type.parse().unwrap_or(StrategyType::EqualWeight); + let status = row.status.parse().unwrap_or(StrategyStatus::Active); + + let config = StrategyConfig { + strategy_id: row.strategy_id.to_string(), + strategy_name: row.strategy_name, + strategy_type, + parameters, + status, + created_at: row.created_at, + updated_at: row.updated_at, + }; + + debug!(strategy_id = %strategy_id, "Strategy retrieved successfully"); + + Ok(config) + } + None => { + error!(strategy_id = %strategy_id, "Strategy not found"); + Err(StrategyError::NotFound(strategy_id.to_string())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strategy_type_display() { + assert_eq!(StrategyType::EqualWeight.to_string(), "EqualWeight"); + assert_eq!(StrategyType::RiskParity.to_string(), "RiskParity"); + assert_eq!(StrategyType::MLOptimized.to_string(), "MLOptimized"); + } + + #[test] + fn test_strategy_type_from_str() { + assert_eq!("EqualWeight".parse::().unwrap(), StrategyType::EqualWeight); + assert_eq!("RiskParity".parse::().unwrap(), StrategyType::RiskParity); + assert!("Invalid".parse::().is_err()); + } + + #[test] + fn test_strategy_status_display() { + assert_eq!(StrategyStatus::Active.to_string(), "Active"); + assert_eq!(StrategyStatus::Paused.to_string(), "Paused"); + assert_eq!(StrategyStatus::Stopped.to_string(), "Stopped"); + } + + #[test] + fn test_strategy_status_from_str() { + assert_eq!("Active".parse::().unwrap(), StrategyStatus::Active); + assert_eq!("Paused".parse::().unwrap(), StrategyStatus::Paused); + assert!("Invalid".parse::().is_err()); + } +} diff --git a/services/trading_agent_service/tests/strategy_tests.rs b/services/trading_agent_service/tests/strategy_tests.rs new file mode 100644 index 000000000..85663d9df --- /dev/null +++ b/services/trading_agent_service/tests/strategy_tests.rs @@ -0,0 +1,447 @@ +//! Integration tests for strategy coordination module + +use trading_agent_service::strategies::{ + StrategyCoordinator, StrategyConfig, StrategyType, StrategyStatus, StrategyError, +}; +use std::collections::HashMap; + +fn get_test_database_url() -> String { + std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()) +} + +async fn setup_test_db() -> sqlx::PgPool { + let pool = sqlx::PgPool::connect(&get_test_database_url()) + .await + .expect("Failed to connect to database"); + + // Run migrations + sqlx::migrate!("../../migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + pool +} + +#[tokio::test] +async fn test_register_strategy() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Create a strategy config + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + parameters.insert("rebalance_threshold".to_string(), 0.02); + + let config = StrategyConfig { + strategy_id: String::new(), // Will be generated + strategy_name: "test_equal_weight_001".to_string(), + strategy_type: StrategyType::EqualWeight, + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let result = coordinator.register_strategy(config).await; + + assert!(result.is_ok(), "Strategy registration should succeed"); + let strategy_id = result.expect("Strategy ID should be returned"); + assert!(!strategy_id.is_empty(), "Strategy ID should not be empty"); + + // Verify it's a valid UUID + assert!(uuid::Uuid::parse_str(&strategy_id).is_ok(), "Strategy ID should be valid UUID"); +} + +#[tokio::test] +async fn test_register_duplicate_strategy_name() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config1 = StrategyConfig { + strategy_id: String::new(), + strategy_name: "duplicate_test_strategy".to_string(), + strategy_type: StrategyType::RiskParity, + parameters: parameters.clone(), + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + // Register first strategy + let result1 = coordinator.register_strategy(config1).await; + assert!(result1.is_ok()); + + // Try to register duplicate name + let config2 = StrategyConfig { + strategy_id: String::new(), + strategy_name: "duplicate_test_strategy".to_string(), + strategy_type: StrategyType::MLOptimized, + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let result2 = coordinator.register_strategy(config2).await; + assert!(result2.is_err(), "Duplicate strategy name should fail"); +} + +#[tokio::test] +async fn test_list_strategies() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Register multiple strategies + for i in 0..3 { + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: format!("test_strategy_list_{}", i), + strategy_type: StrategyType::EqualWeight, + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + coordinator.register_strategy(config).await.expect("Failed to register strategy"); + } + + // List strategies + let result = coordinator.list_strategies().await; + assert!(result.is_ok(), "List strategies should succeed"); + + let strategies = result.expect("Strategies list should be returned"); + assert!(strategies.len() >= 3, "Should have at least 3 strategies"); + + // Verify all strategies have required fields + for strategy in &strategies { + assert!(!strategy.strategy_id.is_empty()); + assert!(!strategy.strategy_name.is_empty()); + } +} + +#[tokio::test] +async fn test_update_strategy_status() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Register a strategy + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: "test_status_update".to_string(), + strategy_type: StrategyType::Momentum, + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let strategy_id = coordinator.register_strategy(config).await.expect("Failed to register"); + + // Update status to Paused + let result = coordinator.update_status(&strategy_id, StrategyStatus::Paused).await; + assert!(result.is_ok(), "Status update should succeed"); + + // Verify status changed + let strategies = coordinator.list_strategies().await.expect("Failed to list"); + let updated_strategy = strategies.iter().find(|s| s.strategy_id == strategy_id); + assert!(updated_strategy.is_some()); + assert_eq!(updated_strategy.unwrap().status, StrategyStatus::Paused); +} + +#[tokio::test] +async fn test_update_nonexistent_strategy_status() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Try to update non-existent strategy + let fake_id = uuid::Uuid::new_v4().to_string(); + let result = coordinator.update_status(&fake_id, StrategyStatus::Stopped).await; + + assert!(result.is_err(), "Updating non-existent strategy should fail"); +} + +#[tokio::test] +async fn test_get_active_strategies() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Register strategies with different statuses + let statuses = vec![ + StrategyStatus::Active, + StrategyStatus::Paused, + StrategyStatus::Active, + StrategyStatus::Stopped, + StrategyStatus::Active, + ]; + + for (i, status) in statuses.iter().enumerate() { + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: format!("test_active_filter_{}", i), + strategy_type: StrategyType::EqualWeight, + parameters, + status: status.clone(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + coordinator.register_strategy(config).await.expect("Failed to register"); + } + + // Get only active strategies + let result = coordinator.get_active_strategies().await; + assert!(result.is_ok(), "Get active strategies should succeed"); + + let active_strategies = result.expect("Active strategies should be returned"); + + // Should have at least 3 active strategies from this test + let test_active_count = active_strategies + .iter() + .filter(|s| s.strategy_name.starts_with("test_active_filter_")) + .count(); + assert_eq!(test_active_count, 3, "Should have exactly 3 active strategies from this test"); + + // All returned strategies should be active + for strategy in &active_strategies { + assert_eq!(strategy.status, StrategyStatus::Active); + } +} + +#[tokio::test] +async fn test_get_strategy_by_id() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Register a strategy + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.08); + parameters.insert("lookback_period".to_string(), 20.0); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: "test_get_by_id".to_string(), + strategy_type: StrategyType::MeanReversion, + parameters: parameters.clone(), + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let strategy_id = coordinator.register_strategy(config).await.expect("Failed to register"); + + // Get strategy by ID + let result = coordinator.get_strategy(&strategy_id).await; + assert!(result.is_ok(), "Get strategy by ID should succeed"); + + let strategy = result.expect("Strategy should be returned"); + assert_eq!(strategy.strategy_id, strategy_id); + assert_eq!(strategy.strategy_name, "test_get_by_id"); + assert_eq!(strategy.strategy_type, StrategyType::MeanReversion); + assert_eq!(strategy.parameters.get("risk_limit"), Some(&0.08)); +} + +#[tokio::test] +async fn test_get_nonexistent_strategy() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let fake_id = uuid::Uuid::new_v4().to_string(); + let result = coordinator.get_strategy(&fake_id).await; + + assert!(result.is_err(), "Getting non-existent strategy should fail"); +} + +#[tokio::test] +async fn test_strategy_performance() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: "test_performance".to_string(), + strategy_type: StrategyType::EqualWeight, + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let start = std::time::Instant::now(); + let result = coordinator.register_strategy(config).await; + let duration = start.elapsed(); + + assert!(result.is_ok(), "Strategy registration should succeed"); + + // Performance target: < 50ms + assert!( + duration.as_millis() < 50, + "Strategy registration took {}ms (target: <50ms)", + duration.as_millis() + ); + + println!("Strategy registration completed in {}ms", duration.as_millis()); +} + +#[tokio::test] +async fn test_list_strategies_performance() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let start = std::time::Instant::now(); + let result = coordinator.list_strategies().await; + let duration = start.elapsed(); + + assert!(result.is_ok(), "List strategies should succeed"); + + // Performance target: < 50ms + assert!( + duration.as_millis() < 50, + "List strategies took {}ms (target: <50ms)", + duration.as_millis() + ); + + println!("List strategies completed in {}ms", duration.as_millis()); +} + +#[tokio::test] +async fn test_update_status_performance() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + // Register a strategy first + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: "test_update_performance".to_string(), + strategy_type: StrategyType::RiskParity, + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let strategy_id = coordinator.register_strategy(config).await.expect("Failed to register"); + + // Measure update performance + let start = std::time::Instant::now(); + let result = coordinator.update_status(&strategy_id, StrategyStatus::Paused).await; + let duration = start.elapsed(); + + assert!(result.is_ok(), "Status update should succeed"); + + // Performance target: < 50ms + assert!( + duration.as_millis() < 50, + "Status update took {}ms (target: <50ms)", + duration.as_millis() + ); + + println!("Status update completed in {}ms", duration.as_millis()); +} + +#[tokio::test] +async fn test_all_strategy_types() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let strategy_types = vec![ + StrategyType::EqualWeight, + StrategyType::RiskParity, + StrategyType::MLOptimized, + StrategyType::MeanVariance, + StrategyType::Momentum, + StrategyType::MeanReversion, + ]; + + for (i, strategy_type) in strategy_types.iter().enumerate() { + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: format!("test_type_{}_{:?}", i, strategy_type), + strategy_type: strategy_type.clone(), + parameters, + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let result = coordinator.register_strategy(config).await; + assert!( + result.is_ok(), + "Should be able to register {:?} strategy", + strategy_type + ); + } +} + +#[tokio::test] +async fn test_empty_parameters() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: "test_empty_params".to_string(), + strategy_type: StrategyType::EqualWeight, + parameters: HashMap::new(), // Empty parameters + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let result = coordinator.register_strategy(config).await; + assert!(result.is_ok(), "Empty parameters should be allowed"); +} + +#[tokio::test] +async fn test_complex_parameters() { + let pool = setup_test_db().await; + let coordinator = StrategyCoordinator::new(pool); + + let mut parameters = HashMap::new(); + parameters.insert("risk_limit".to_string(), 0.05); + parameters.insert("rebalance_threshold".to_string(), 0.02); + parameters.insert("lookback_period".to_string(), 20.0); + parameters.insert("volatility_target".to_string(), 0.15); + parameters.insert("max_position_size".to_string(), 0.10); + + let config = StrategyConfig { + strategy_id: String::new(), + strategy_name: "test_complex_params".to_string(), + strategy_type: StrategyType::MLOptimized, + parameters: parameters.clone(), + status: StrategyStatus::Active, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let strategy_id = coordinator.register_strategy(config).await.expect("Failed to register"); + + // Verify parameters were stored correctly + let strategy = coordinator.get_strategy(&strategy_id).await.expect("Failed to get strategy"); + assert_eq!(strategy.parameters.len(), 5); + assert_eq!(strategy.parameters.get("risk_limit"), Some(&0.05)); + assert_eq!(strategy.parameters.get("lookback_period"), Some(&20.0)); +}