diff --git a/src/agent.rs b/src/agent.rs index 56a4e7c..a05de06 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -570,7 +570,8 @@ async fn run_single_backtest( .await .context("poll")?; - Ok(BacktestResult::from_response(&final_resp, &inst.symbol)) + let initial_bal: f64 = initial_balance.parse().unwrap_or(10000.0); + Ok(BacktestResult::from_response(&final_resp, &inst.symbol, &inst.quote(), initial_bal)) } fn save_validated_strategy( diff --git a/src/swym.rs b/src/swym.rs index ec89095..0aa9519 100644 --- a/src/swym.rs +++ b/src/swym.rs @@ -69,8 +69,17 @@ pub struct BacktestResult { } impl BacktestResult { - /// Parse a backtest response from the flat `result_summary` structure. - pub fn from_response(resp: &PaperRunResponse, instrument: &str) -> Self { + /// Parse a backtest response. + /// + /// `quote` is the lowercase quote asset name (e.g. "usdc") used to locate + /// the ending balance in `result_summary.assets[].tear_sheet.balance_end`. + /// `initial_balance` is the starting quote balance used to compute net PnL. + pub fn from_response( + resp: &PaperRunResponse, + instrument: &str, + quote: &str, + initial_balance: f64, + ) -> Self { let summary = resp.result_summary.as_ref(); if let Some(s) = summary { tracing::debug!("[{instrument}] result_summary: {}", serde_json::to_string(s).unwrap_or_default()); @@ -78,20 +87,35 @@ impl BacktestResult { tracing::debug!("[{instrument}] result_summary: null"); } + // Actual structure: { backtest_metadata: { position_count }, assets: [...], condition_audit_summary } + let total_positions = summary.and_then(|s| { + s["backtest_metadata"]["position_count"].as_u64().map(|v| v as u32) + }); + + // net_pnl = ending quote balance − starting quote balance + let net_pnl = summary.and_then(|s| { + s["assets"].as_array()?.iter() + .find(|a| a["asset"].as_str().unwrap_or("") == quote)? + ["tear_sheet"]["balance_end"]["total"] + .as_str() + .and_then(|t| t.parse::().ok()) + .map(|end| end - initial_balance) + }); + Self { run_id: resp.id, instrument: instrument.to_string(), status: resp.status.clone(), - total_positions: summary.and_then(|s| s["total_positions"].as_u64().map(|v| v as u32)), - winning_positions: summary.and_then(|s| s["winning_positions"].as_u64().map(|v| v as u32)), - losing_positions: summary.and_then(|s| s["losing_positions"].as_u64().map(|v| v as u32)), - win_rate: summary.and_then(|s| parse_number(&s["win_rate"])), - profit_factor: summary.and_then(|s| parse_number(&s["profit_factor"])), - total_pnl: summary.and_then(|s| parse_number(&s["total_pnl"])), - net_pnl: summary.and_then(|s| parse_number(&s["net_pnl"])), - sharpe_ratio: summary.and_then(|s| parse_number(&s["sharpe_ratio"])), - total_fees: summary.and_then(|s| parse_number(&s["total_fees"])), - avg_bars_in_trade: summary.and_then(|s| parse_number(&s["avg_bars_in_trade"])), + total_positions, + winning_positions: None, + losing_positions: None, + win_rate: None, + profit_factor: None, + total_pnl: net_pnl, + net_pnl, + sharpe_ratio: None, + total_fees: None, + avg_bars_in_trade: None, error_message: resp.error_message.clone(), condition_audit_summary: summary.and_then(|s| s.get("condition_audit_summary").cloned()), } @@ -107,14 +131,20 @@ impl BacktestResult { ); } let mut s = format!( - "[{}] trades={} win_rate={:.1}% pf={:.2} net_pnl={:.2} sharpe={:.2}", + "[{}] trades={} net_pnl={:.2}", self.instrument, self.total_positions.unwrap_or(0), - self.win_rate.unwrap_or(0.0) * 100.0, - self.profit_factor.unwrap_or(0.0), self.net_pnl.unwrap_or(0.0), - self.sharpe_ratio.unwrap_or(0.0), ); + if let Some(wr) = self.win_rate { + s.push_str(&format!(" win_rate={:.1}%", wr * 100.0)); + } + if let Some(pf) = self.profit_factor { + s.push_str(&format!(" pf={:.2}", pf)); + } + if let Some(sr) = self.sharpe_ratio { + s.push_str(&format!(" sharpe={:.2}", sr)); + } if self.total_positions.unwrap_or(0) == 0 { if let Some(audit) = &self.condition_audit_summary { let audit_str = format_audit_summary(audit); @@ -128,11 +158,15 @@ impl BacktestResult { } /// Is this result promising enough to warrant out-of-sample validation? + /// Uses sharpe if available, otherwise falls back to net_pnl > 0. pub fn is_promising(&self, min_sharpe: f64, min_trades: u32) -> bool { - self.status == "complete" - && self.sharpe_ratio.unwrap_or(0.0) > min_sharpe - && self.total_positions.unwrap_or(0) >= min_trades - && self.net_pnl.unwrap_or(0.0) > 0.0 + if self.status != "complete" { return false; } + if self.total_positions.unwrap_or(0) < min_trades { return false; } + if self.net_pnl.unwrap_or(0.0) <= 0.0 { return false; } + match self.sharpe_ratio { + Some(sr) => sr > min_sharpe, + None => true, // sharpe not yet in API response; net_pnl + trades is sufficient signal + } } }