//! Order Generation Logic //! //! Converts portfolio allocations to executable orders, accounting for: //! - Current positions (delta orders) //! - Order size constraints (min/max) //! - Rebalance thresholds //! - Database persistence use bigdecimal::BigDecimal; use chrono::{DateTime, Utc}; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; use std::str::FromStr; use tracing::{debug, error, info, warn}; #[cfg(test)] use rust_decimal_macros::dec; use common::{Order, OrderSide, OrderType, Position, Quantity, Symbol}; /// Error types for order generation #[derive(Debug, thiserror::Error)] pub enum OrderError { #[error("Database error: {0}")] Database(#[from] sqlx::Error), #[error("Invalid allocation: {reason}")] InvalidAllocation { reason: String }, #[error("Failed to parse BigDecimal: {0}")] ParseBigDecimal(#[from] bigdecimal::ParseBigDecimalError), #[error("Order size below minimum: {symbol} = ${size:.2} (min: ${min_size:.2})")] OrderSizeBelowMinimum { symbol: String, size: f64, min_size: f64, }, #[error("Order size exceeds maximum: {symbol} = ${size:.2} (max: ${max_size:.2})")] OrderSizeExceedsMaximum { symbol: String, size: f64, max_size: f64, }, #[error("Position not found: {symbol}")] PositionNotFound { symbol: String }, #[error("Invalid quantity: {reason}")] InvalidQuantity { reason: String }, #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), #[error("Insufficient data: {reason}")] InsufficientData { reason: String }, #[error("Regime detection error: {0}")] RegimeDetection(String), } /// Portfolio allocation with symbol weights #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortfolioAllocation { /// Unique allocation identifier pub allocation_id: String, /// Strategy identifier that generated this allocation pub strategy_id: String, /// Total capital to allocate pub total_capital: Decimal, /// Symbol weights (must sum to <= 1.0) pub symbol_weights: HashMap, /// Rebalance threshold (0.0-1.0) pub rebalance_threshold: f64, /// Maximum position size as fraction of capital (0.0-1.0) pub max_position_size: f64, /// Allocation creation timestamp pub created_at: DateTime, } impl PortfolioAllocation { /// Validate allocation parameters pub fn validate(&self) -> Result<(), OrderError> { // Check total capital if self.total_capital <= Decimal::ZERO { return Err(OrderError::InvalidAllocation { reason: "Total capital must be positive".to_string(), }); } // Check weights sum let weight_sum: f64 = self.symbol_weights.values().sum(); if weight_sum > 1.01 { // Allow small rounding tolerance return Err(OrderError::InvalidAllocation { reason: format!("Symbol weights sum to {:.4}, must be <= 1.0", weight_sum), }); } // Check individual weights for (symbol, &weight) in &self.symbol_weights { if !(0.0..=1.0).contains(&weight) { return Err(OrderError::InvalidAllocation { reason: format!( "Weight for {} is {:.4}, must be in [0.0, 1.0]", symbol, weight ), }); } } // Check rebalance threshold if !(0.0..=1.0).contains(&self.rebalance_threshold) { return Err(OrderError::InvalidAllocation { reason: format!( "Rebalance threshold is {:.4}, must be in [0.0, 1.0]", self.rebalance_threshold ), }); } // Check max position size if !(0.0..=1.0).contains(&self.max_position_size) { return Err(OrderError::InvalidAllocation { reason: format!( "Max position size is {:.4}, must be in [0.0, 1.0]", self.max_position_size ), }); } Ok(()) } } /// Order generator implementation pub struct OrderGenerator { pool: PgPool, min_order_size: f64, max_order_size: f64, } impl OrderGenerator { /// Create a new order generator /// /// # Arguments /// * `pool` - Database connection pool /// * `min_order_size` - Minimum order size in USD /// * `max_order_size` - Maximum order size in USD pub fn new(pool: PgPool, min_order_size: f64, max_order_size: f64) -> Self { Self { pool, min_order_size, max_order_size, } } /// Generate orders from portfolio allocation /// /// # Arguments /// * `allocation` - Portfolio allocation with symbol weights /// * `current_positions` - Current positions to account for /// /// # Returns /// Vector of orders to execute /// /// # Errors /// Returns error if validation fails or database operation fails pub async fn generate_orders( &self, allocation: &PortfolioAllocation, current_positions: &[Position], ) -> Result, OrderError> { let start = std::time::Instant::now(); // Validate allocation allocation.validate()?; debug!( "Generating orders for allocation {} with {} symbols", allocation.allocation_id, allocation.symbol_weights.len() ); // Calculate target positions let target_positions = self.calculate_target_positions(allocation)?; // Calculate current position values let current_position_map = self.build_position_map(current_positions); // Calculate deltas and generate orders let mut orders = Vec::new(); for (symbol, target_value) in &target_positions { let current_value = current_position_map.get(symbol).copied().unwrap_or(0.0); let delta = target_value - current_value; // Check if delta exceeds rebalance threshold let threshold_value = allocation.total_capital.to_f64().unwrap_or(0.0) * allocation.rebalance_threshold; if delta.abs() < threshold_value { debug!( "Skipping {} - delta ${:.2} below threshold ${:.2}", symbol, delta, threshold_value ); continue; } // Create order if delta is significant if let Some(order) = self.create_order(allocation, symbol, delta, current_positions).await? { orders.push(order); } } // Store orders in database self.store_orders(&orders).await?; let duration = start.elapsed(); info!( "Generated {} orders for allocation {} in {}ms", orders.len(), allocation.allocation_id, duration.as_millis() ); Ok(orders) } /// Calculate target position values from allocation fn calculate_target_positions( &self, allocation: &PortfolioAllocation, ) -> Result, OrderError> { let mut target_positions = HashMap::new(); let total_capital = allocation .total_capital .to_f64() .ok_or_else(|| OrderError::InvalidAllocation { reason: "Failed to convert total capital to f64".to_string(), })?; for (symbol, &weight) in &allocation.symbol_weights { let target_value = total_capital * weight; target_positions.insert(symbol.clone(), target_value); debug!( "Target for {}: ${:.2} ({:.2}%)", symbol, target_value, weight * 100.0 ); } Ok(target_positions) } /// Build map of current position values fn build_position_map(&self, positions: &[Position]) -> HashMap { let mut position_map = HashMap::new(); for position in positions { // Calculate position value: quantity * current_price (or avg_price if no current price) let price = position .current_price .unwrap_or(position.avg_price) .to_f64() .unwrap_or(0.0); let value = position.quantity.to_f64().unwrap_or(0.0) * price; position_map.insert(position.symbol.clone(), value); debug!( "Current position {}: ${:.2} ({} @ ${:.2})", position.symbol, value, position.quantity, price ); } position_map } /// Create order from delta async fn create_order( &self, allocation: &PortfolioAllocation, symbol: &str, delta: f64, current_positions: &[Position], ) -> Result, OrderError> { let abs_delta = delta.abs(); // Check minimum order size if abs_delta < self.min_order_size { debug!( "Skipping {} - delta ${:.2} below minimum ${:.2}", symbol, abs_delta, self.min_order_size ); return Ok(None); } // Check maximum order size if abs_delta > self.max_order_size { warn!( "Order for {} exceeds maximum: ${:.2} > ${:.2}", symbol, abs_delta, self.max_order_size ); return Err(OrderError::OrderSizeExceedsMaximum { symbol: symbol.to_string(), size: abs_delta, max_size: self.max_order_size, }); } // Determine order side let side = if delta > 0.0 { OrderSide::Buy } else { OrderSide::Sell }; // Calculate quantity based on estimated contract price // For futures, we need to convert dollar amount to contracts let estimated_price = self.estimate_contract_price(symbol, current_positions)?; let quantity_float = abs_delta / estimated_price; let quantity = Decimal::try_from(quantity_float).map_err(|e| OrderError::InvalidQuantity { reason: format!("Failed to convert quantity {}: {}", quantity_float, e), })?; if quantity <= Decimal::ZERO { return Ok(None); } // Create order let symbol_obj: Symbol = symbol.into(); let quantity_obj = Quantity::from_decimal(quantity).map_err(|e| OrderError::InvalidAllocation { reason: format!("Failed to convert quantity: {}", e), })?; let mut order = Order::new( symbol_obj.clone(), side, quantity_obj, None, // Market order - no price OrderType::Market, ); // Set additional fields order.client_order_id = Some(format!("agent_{}", Uuid::new_v4())); order.metadata = serde_json::json!({ "allocation_id": allocation.allocation_id, "strategy_id": allocation.strategy_id, "delta_usd": delta, "estimated_price": estimated_price, }); debug!( "Created {} order for {}: {} @ ~${:.2}", side, symbol, quantity, estimated_price ); // Apply regime-adaptive dynamic stop-loss let order = crate::dynamic_stop_loss::apply_dynamic_stop_loss( order, symbol, &self.pool, ) .await .map_err(|e| { warn!("Failed to apply dynamic stop-loss for {}: {}", symbol, e); e })?; Ok(Some(order)) } /// Estimate contract price from current positions or historical data fn estimate_contract_price( &self, symbol: &str, current_positions: &[Position], ) -> Result { // Try to get price from current position if let Some(position) = current_positions.iter().find(|p| p.symbol == symbol) { let price = position .current_price .unwrap_or(position.avg_price) .to_f64() .unwrap_or(0.0); if price > 0.0 { return Ok(price); } } // Fallback to typical contract prices (hardcoded for MVP) // In production, this would query market data API let typical_price = match symbol { "ES.FUT" => 5000.0, // E-mini S&P 500 "NQ.FUT" => 20000.0, // E-mini Nasdaq "ZN.FUT" => 110.0, // 10-Year Treasury Note "6E.FUT" => 1.10, // Euro FX "CL.FUT" => 80.0, // Crude Oil "GC.FUT" => 2000.0, // Gold "SI.FUT" => 25.0, // Silver "YM.FUT" => 40000.0, // Mini Dow "RTY.FUT" => 2000.0, // Russell 2000 "ZB.FUT" => 120.0, // 30-Year Treasury Bond "ZC.FUT" => 500.0, // Corn "ZS.FUT" => 1400.0, // Soybeans "ZW.FUT" => 600.0, // Wheat "NG.FUT" => 3.0, // Natural Gas "HO.FUT" => 2.5, // Heating Oil "RB.FUT" => 2.5, // RBOB Gasoline "6A.FUT" => 0.70, // Australian Dollar "6B.FUT" => 1.30, // British Pound "6C.FUT" => 0.75, // Canadian Dollar "6J.FUT" => 0.007, // Japanese Yen _ => 1000.0, // Generic fallback }; Ok(typical_price) } /// Store orders in database async fn store_orders(&self, orders: &[Order]) -> Result<(), OrderError> { if orders.is_empty() { return Ok(()); } for order in orders { let order_id = order.id.to_string(); let allocation_id = order .metadata .get("allocation_id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let symbol = order.symbol.as_str(); let side = order.side.to_string(); let quantity_decimal: Decimal = order.quantity.into(); let quantity = BigDecimal::from_str(&quantity_decimal.to_string())?; let price: Option = order.price.and_then(|p| { let p_dec: Decimal = p.into(); BigDecimal::from_str(&p_dec.to_string()).ok() }); let order_type = format!("{:?}", order.order_type).to_uppercase(); let status = format!("{:?}", order.status).to_uppercase(); let time_in_force = format!("{:?}", order.time_in_force).to_uppercase(); let metadata = serde_json::to_value(&order.metadata)?; sqlx::query!( r#" INSERT INTO agent_orders ( order_id, allocation_id, symbol, side, quantity, price, order_type, status, time_in_force, filled_quantity, client_order_id, created_at, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) "#, order_id, allocation_id, symbol, side, quantity, price, order_type, status, time_in_force, BigDecimal::from_str("0").unwrap(), // filled_quantity order.client_order_id, order.created_at.to_datetime(), metadata, ) .execute(&self.pool) .await?; debug!("Stored order {} in database", order_id); } Ok(()) } } // Re-export Uuid for tests use uuid::Uuid; #[cfg(test)] mod tests { use super::*; #[test] fn test_allocation_validation_valid() { let mut weights = HashMap::new(); weights.insert("ES.FUT".to_string(), 0.5); weights.insert("NQ.FUT".to_string(), 0.5); let allocation = PortfolioAllocation { allocation_id: "test".to_string(), strategy_id: "test".to_string(), total_capital: dec!(1_000_000), symbol_weights: weights, rebalance_threshold: 0.05, max_position_size: 0.20, created_at: Utc::now(), }; assert!(allocation.validate().is_ok()); } #[test] fn test_allocation_validation_zero_capital() { let mut weights = HashMap::new(); weights.insert("ES.FUT".to_string(), 1.0); let allocation = PortfolioAllocation { allocation_id: "test".to_string(), strategy_id: "test".to_string(), total_capital: Decimal::ZERO, symbol_weights: weights, rebalance_threshold: 0.05, max_position_size: 0.20, created_at: Utc::now(), }; assert!(allocation.validate().is_err()); } #[test] fn test_allocation_validation_weights_exceed_one() { let mut weights = HashMap::new(); weights.insert("ES.FUT".to_string(), 0.8); weights.insert("NQ.FUT".to_string(), 0.8); let allocation = PortfolioAllocation { allocation_id: "test".to_string(), strategy_id: "test".to_string(), total_capital: dec!(1_000_000), symbol_weights: weights, rebalance_threshold: 0.05, max_position_size: 0.20, created_at: Utc::now(), }; assert!(allocation.validate().is_err()); } #[test] fn test_estimate_contract_price_es() { // Wrap in tokio runtime to avoid "requires a Tokio context" error from PgPool::connect_lazy let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { let pool = PgPool::connect_lazy("postgresql://localhost/test") .expect("Failed to create pool"); let generator = OrderGenerator::new(pool, 100.0, 100_000.0); let positions = vec![]; let price = generator .estimate_contract_price("ES.FUT", &positions) .expect("Should estimate price"); assert_eq!(price, 5000.0); }); } #[tokio::test] async fn test_build_position_map() { let pool = PgPool::connect_lazy("postgresql://localhost/test").expect("Failed to create pool"); let generator = OrderGenerator::new(pool, 100.0, 100_000.0); let now = Utc::now(); let positions = vec![Position { id: Uuid::new_v4(), symbol: "ES.FUT".to_string(), quantity: dec!(100), avg_price: dec!(5000.0), avg_cost: dec!(5000.0), basis: dec!(500_000), average_price: dec!(5000.0), market_value: dec!(505_000), unrealized_pnl: dec!(5_000), realized_pnl: Decimal::ZERO, created_at: now, updated_at: now, last_updated: now, current_price: Some(dec!(5050.0)), margin_requirement: Decimal::ZERO, notional_value: dec!(505_000), }]; let position_map = generator.build_position_map(&positions); assert_eq!(position_map.len(), 1); assert!(position_map.contains_key("ES.FUT")); // Value = 100 * 5050.0 = 505,000 let value = position_map.get("ES.FUT").copied().unwrap_or(0.0); assert!((value - 505_000.0).abs() < 1.0); } }