diff --git a/services/trading_agent_service/src/service.rs b/services/trading_agent_service/src/service.rs index ff479278e..9dd4ad005 100644 --- a/services/trading_agent_service/src/service.rs +++ b/services/trading_agent_service/src/service.rs @@ -673,7 +673,11 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm return Err(Status::invalid_argument("Total capital must be positive")); } - // 1. Run regime detection for each symbol + // 1. Fetch bars, run regime detection, and compute per-asset market data + // We retain the bars to derive volatility and last price for each symbol. + let mut symbol_volatilities: HashMap = HashMap::new(); + let mut symbol_last_prices: HashMap = HashMap::new(); + for asset in &req.assets { let bars = self.fetch_recent_bars(&asset.symbol, 100).await?; @@ -686,6 +690,38 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm continue; } + // Compute annualized volatility from daily log returns + let log_returns: Vec = bars + .windows(2) + .filter_map(|w| { + let prev_close = w.first().map(|b| b.close)?; + let curr_close = w.last().map(|b| b.close)?; + if prev_close > 0.0 { + Some((curr_close / prev_close).ln()) + } else { + None + } + }) + .collect(); + + if !log_returns.is_empty() { + let n = log_returns.len() as f64; + let mean = log_returns.iter().sum::() / n; + let variance = + log_returns.iter().map(|r| (r - mean).powi(2)).sum::() / (n - 1.0).max(1.0); + let daily_vol = variance.sqrt(); + // Annualize: daily_vol * sqrt(252 trading days) + let annual_vol = daily_vol * (252.0_f64).sqrt(); + symbol_volatilities.insert(asset.symbol.clone(), annual_vol.max(0.001)); + } + + // Record last close price for target_quantity calculation + if let Some(last_bar) = bars.last() { + if last_bar.close > 0.0 { + symbol_last_prices.insert(asset.symbol.clone(), last_bar.close); + } + } + let mut orchestrator = self.regime_orchestrator.lock().await; orchestrator .detect_and_persist(&asset.symbol, &bars) @@ -698,18 +734,24 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm info!("Regime detection complete for {}", asset.symbol); } - // 2. Build AssetInfo from request + // 2. Build AssetInfo from request, using real volatility when available let assets: Vec = req .assets .iter() - .map(|a| AssetInfo { - symbol: a.symbol.clone(), - expected_return: a.composite_score, // Use composite score as expected return proxy - volatility: 0.15, // Default 15% volatility (should be fetched from market data in production) - win_rate: 0.55, // Default 55% win rate (should be from historical backtest) - avg_win: 0.02, // Default 2% avg win (should be from historical backtest) - avg_loss: 0.01, // Default 1% avg loss (should be from historical backtest) - ml_score: a.ml_score, + .map(|a| { + let volatility = symbol_volatilities + .get(&a.symbol) + .copied() + .unwrap_or(0.15); // Fallback: 15% if bars were insufficient + AssetInfo { + symbol: a.symbol.clone(), + expected_return: a.composite_score, // Use composite score as expected return proxy + volatility, + win_rate: 0.55, // Default 55% win rate (should be from historical backtest) + avg_win: 0.02, // Default 2% avg win (should be from historical backtest) + avg_loss: 0.01, // Default 1% avg loss (should be from historical backtest) + ml_score: a.ml_score, + } }) .collect(); @@ -729,40 +771,103 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm Status::internal(format!("Allocation failed: {}", e)) })?; - // 4. Convert to proto + // 4. Convert to proto — compute target_quantity from last price + // + // BLOCKER: current_weight and current_quantity require live position data from + // a portfolio/position-tracking service (or a positions table). This service does + // not currently have access to live position state. When a PositionService gRPC + // client is added, these should be fetched per-symbol. + // rebalance_delta = target_quantity - current_quantity (set to target_quantity + // until current positions are available). let proto_allocations: Vec = allocations .iter() .map(|(symbol, capital)| { let capital_f64 = capital.to_f64().unwrap_or(0.0); - let weight = capital_f64 / req.total_capital; + let weight = if req.total_capital > 0.0 { + capital_f64 / req.total_capital + } else { + 0.0 + }; + + // target_quantity = target_capital / last_close_price + let target_quantity = symbol_last_prices + .get(symbol) + .filter(|p| **p > 0.0) + .map(|price| capital_f64 / price) + .unwrap_or(0.0); // 0.0 if no price data available + AssetAllocation { symbol: symbol.clone(), target_weight: weight, target_capital: capital_f64, - target_quantity: 0.0, // TODO: Calculate from price data - current_weight: 0.0, // TODO: Fetch from position data - current_quantity: 0.0, // TODO: Fetch from position data - rebalance_delta: 0.0, // TODO: Calculate from current vs target + target_quantity, + // TODO(positions): Fetch from live position service / positions table. + // Requires PositionService gRPC client or position-tracking DB query. + current_weight: 0.0, + current_quantity: 0.0, + // Once current_quantity is available: target_quantity - current_quantity + rebalance_delta: target_quantity, } }) .collect(); - // 5. Calculate metrics + // 5. Calculate portfolio metrics using real per-asset volatilities let total_weight: f64 = proto_allocations.iter().map(|a| a.target_weight).sum(); let total_allocated: f64 = proto_allocations.iter().map(|a| a.target_capital).sum(); - // Calculate portfolio volatility (simplified: weighted average) - let portfolio_volatility: f64 = proto_allocations + // Weighted expected return (composite_score * weight for each asset) + let weighted_expected_return: f64 = proto_allocations .iter() - .map(|a| a.target_weight * 0.15) // Using default volatility + .filter_map(|a| { + req.assets + .iter() + .find(|ra| ra.symbol == a.symbol) + .map(|ra| a.target_weight * ra.composite_score) + }) .sum(); + // Portfolio volatility: sqrt(sum(w_i^2 * sigma_i^2)) + // This is the diagonal-only (uncorrelated) approximation. + // TODO(correlation): For full covariance, need cross-asset return correlations. + let portfolio_variance: f64 = proto_allocations + .iter() + .map(|a| { + let vol = symbol_volatilities + .get(&a.symbol) + .copied() + .unwrap_or(0.15); + a.target_weight.powi(2) * vol.powi(2) + }) + .sum(); + let portfolio_volatility = portfolio_variance.sqrt(); + + // Sharpe ratio: (weighted_expected_return - risk_free_rate) / portfolio_volatility + // Using 5% annualized risk-free rate (approximate US T-bill rate) + let risk_free_rate = 0.05; + let portfolio_sharpe = if portfolio_volatility > 1e-10 { + (weighted_expected_return - risk_free_rate) / portfolio_volatility + } else { + 0.0 + }; + + // Parametric VaR (95%): portfolio_value * z_95 * daily_volatility + // z_95 = 1.6449 (one-sided 95th percentile of standard normal) + // daily_vol = annual_vol / sqrt(252) + let z_95 = 1.6449; + let daily_portfolio_vol = portfolio_volatility / (252.0_f64).sqrt(); + let var_95 = req.total_capital * z_95 * daily_portfolio_vol; + + // Max drawdown estimate via volatility-based approximation: + // E[MaxDD] ~ volatility * sqrt(2 * ln(T)) where T = 252 trading days + // This is a rough estimate for a 1-year horizon. + let max_drawdown_estimate = portfolio_volatility * (2.0 * (252.0_f64).ln()).sqrt(); + let metrics = AllocationMetrics { total_weight, portfolio_volatility, - portfolio_sharpe: 0.0, // TODO: Calculate from historical returns - var_95: 0.0, // TODO: Calculate Value at Risk - max_drawdown_estimate: 0.0, // TODO: Estimate from historical data + portfolio_sharpe, + var_95, + max_drawdown_estimate, }; let duration_ms = start.elapsed().as_millis() as f64; @@ -1669,6 +1774,11 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm last_action_timestamp: chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0), }; + // BLOCKER(agent-status performance): This is a lightweight status endpoint. + // For real metrics, call GetAgentPerformance which queries the orders table. + // Wiring this endpoint to the same DB queries would duplicate logic; instead, + // callers should use GetAgentPerformance for accurate metrics. When a + // performance cache/snapshot is introduced, populate these from the cache. let performance = if req.include_performance { Some(AgentPerformanceMetrics { total_pnl: 0.0, @@ -1864,7 +1974,12 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm win_rate, total_trades, avg_trade_pnl, - portfolio_turnover: 0.0, // TODO: compute from historical position changes + // BLOCKER(portfolio_turnover): Requires historical position snapshots table + // (e.g., daily position weights) to compute sum(|w_t - w_{t-1}|) over time. + // Currently no position-snapshot persistence exists in this service. + // When position tracking is added (via PositionService or a positions_history + // table), compute: sum of absolute weight changes / 2, annualized. + portfolio_turnover: 0.0, period_start, period_end, }; @@ -1877,11 +1992,17 @@ impl trading_agent_service_server::TradingAgentService for TradingAgentServiceIm .await .unwrap_or_default(); + // BLOCKER(per-strategy P&L): Order metadata currently lacks a strategy_id + // field, so we cannot attribute trades to individual strategies. To fix: + // 1. Add strategy_id to order metadata when orders are generated + // 2. Filter order_rows by strategy_id per strategy + // 3. Compute per-strategy P&L, sharpe, win_rate, total_trades + // Until then, per-strategy metrics remain zeroed. strategies .iter() .map(|s| StrategyPerformance { strategy_id: s.strategy_id.clone(), - total_pnl: 0.0, // TODO: per-strategy P&L tracking + total_pnl: 0.0, sharpe_ratio: 0.0, win_rate: 0.0, total_trades: 0,