fix(trading_agent): wire real volatility, prices, and metrics into allocate_portfolio
Replace 7 hardcoded 0.0 values with real calculations: - target_quantity from last close price - portfolio_volatility from log return stddev * sqrt(252) - portfolio_sharpe from weighted returns / portfolio vol - var_95 parametric VaR - max_drawdown_estimate from vol approximation - rebalance_delta as target - current (0 until positions available) - per-asset volatility from price bars (was hardcoded 0.15) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, f64> = HashMap::new();
|
||||
let mut symbol_last_prices: HashMap<String, f64> = 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<f64> = 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::<f64>() / n;
|
||||
let variance =
|
||||
log_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (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<AssetInfo> = 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<AssetAllocation> = 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,
|
||||
|
||||
Reference in New Issue
Block a user