diff --git a/apps/stock/core/index.node b/apps/stock/core/index.node index d7c42a1..883f541 100755 Binary files a/apps/stock/core/index.node and b/apps/stock/core/index.node differ diff --git a/apps/stock/core/src/api/backtest.rs b/apps/stock/core/src/api/backtest.rs index cb49e70..a9cf5ae 100644 --- a/apps/stock/core/src/api/backtest.rs +++ b/apps/stock/core/src/api/backtest.rs @@ -183,7 +183,7 @@ impl BacktestEngine { eprintln!("=== BACKTEST RUN COMPLETE ==="); eprintln!("Total trades: {}", result.trades.len()); - eprintln!("Equity curve length: {}", result.equity_curve.len()); + eprintln!("Equity points: {}", result.equity.len()); // Return result as JSON serde_json::to_string(&result) diff --git a/apps/stock/core/src/backtest/engine.rs b/apps/stock/core/src/backtest/engine.rs index d3e2320..82e9f69 100644 --- a/apps/stock/core/src/backtest/engine.rs +++ b/apps/stock/core/src/backtest/engine.rs @@ -480,52 +480,27 @@ impl BacktestEngine { fn generate_results(&self) -> BacktestResult { let state = self.state.read(); - let (realized_pnl, unrealized_pnl) = self.position_tracker.get_total_pnl(); - let total_pnl = realized_pnl + unrealized_pnl; - let total_return = (total_pnl / self.config.initial_capital) * 100.0; + let start_time = self.config.start_time; - // Get completed trades from trade tracker for metrics + // Get final positions + let final_positions = self.position_tracker.get_all_positions() + .into_iter() + .map(|p| (p.symbol.clone(), p)) + .collect(); + + // Get completed trades from tracker let completed_trades = self.trade_tracker.get_completed_trades(); - // Calculate metrics from completed trades - let completed_trade_count = completed_trades.len(); - let profitable_trades = completed_trades.iter().filter(|t| t.pnl > 0.0).count(); - let total_wins: f64 = completed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); - let total_losses: f64 = completed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); - let avg_win = if profitable_trades > 0 { total_wins / profitable_trades as f64 } else { 0.0 }; - let avg_loss = if completed_trade_count > profitable_trades { - total_losses / (completed_trade_count - profitable_trades) as f64 - } else { 0.0 }; - let profit_factor = if total_losses > 0.0 { total_wins / total_losses } else { 0.0 }; - - // For the API, return all fills (not just completed trades) - // This shows all trading activity - let all_fills = state.completed_trades.clone(); - let total_trades = all_fills.len(); - - BacktestResult { - config: self.config.clone(), - metrics: super::BacktestMetrics { - total_return, - total_trades, - profitable_trades, - win_rate: if completed_trade_count > 0 { - (profitable_trades as f64 / completed_trade_count as f64) * 100.0 - } else { 0.0 }, - profit_factor, - sharpe_ratio: 0.0, // TODO: Calculate properly - max_drawdown: 0.0, // TODO: Calculate properly - total_pnl, - avg_win, - avg_loss, - }, - equity_curve: state.equity_curve.clone(), - trades: all_fills, - final_positions: self.position_tracker.get_all_positions() - .into_iter() - .map(|p| (p.symbol.clone(), p)) - .collect(), - } + // Use simple results builder with proper trade data + BacktestResult::from_engine_data_with_trades( + self.config.clone(), + state.equity_curve.clone(), + state.completed_trades.clone(), + completed_trades, + final_positions, + start_time, + &self.last_prices, + ) } } diff --git a/apps/stock/core/src/backtest/metrics_calculator.rs b/apps/stock/core/src/backtest/metrics_calculator.rs new file mode 100644 index 0000000..71f3560 --- /dev/null +++ b/apps/stock/core/src/backtest/metrics_calculator.rs @@ -0,0 +1,669 @@ +use chrono::{DateTime, Utc, Datelike}; +use std::collections::HashMap; +use super::results::*; +use super::{BacktestConfig, CompletedTrade}; +use super::trade_tracker::CompletedTrade as TrackedTrade; +use crate::{Position, Side}; + +pub struct MetricsCalculator { + config: BacktestConfig, + equity_curve: Vec<(DateTime, f64)>, + trades: Vec, + fills: Vec, + positions: HashMap, +} + +impl MetricsCalculator { + pub fn new( + config: BacktestConfig, + equity_curve: Vec<(DateTime, f64)>, + trades: Vec, + fills: Vec, + positions: HashMap, + ) -> Self { + Self { + config, + equity_curve, + trades, + fills, + positions, + } + } + + pub fn calculate_all_metrics(&self) -> BacktestResult { + let start_time = Utc::now(); + let backtest_id = format!("rust-{}", start_time.timestamp_millis()); + + // Calculate comprehensive metrics + let metrics = self.calculate_metrics(); + + // Convert equity curve + let equity_points: Vec = self.equity_curve.iter() + .map(|(dt, value)| EquityPoint { + timestamp: dt.timestamp_millis(), + value: *value, + }) + .collect(); + + // Calculate drawdown curve + let drawdown_curve = self.calculate_drawdown_curve(); + + // Calculate returns + let daily_returns = self.calculate_daily_returns(); + let cumulative_returns = self.calculate_cumulative_returns(); + + // Convert trades to UI format + let trades = self.convert_fills_to_trades(); + let open_trades = self.get_open_trades(); + + // Period analysis + let monthly_returns = self.calculate_period_returns("monthly"); + let yearly_returns = self.calculate_period_returns("yearly"); + + // Symbol analysis + let symbol_analysis = self.calculate_symbol_analysis(); + + // Drawdown periods + let drawdown_periods = self.analyze_drawdown_periods(); + + // Exposure analysis + let exposure_analysis = self.calculate_exposure_analysis(); + + // Position history + let position_history = self.calculate_position_history(); + + // Trade signals (extracted from trades) + let trade_signals = self.extract_trade_signals(); + + BacktestResult { + backtest_id, + status: "completed".to_string(), + started_at: self.config.start_time.to_rfc3339(), + completed_at: Utc::now().to_rfc3339(), + execution_time_ms: (Utc::now() - start_time).num_milliseconds() as u64, + config: self.config.clone(), + metrics, + equity_curve: equity_points, + drawdown_curve, + daily_returns, + cumulative_returns, + trades, + open_trades, + trade_signals, + positions: self.positions.clone(), + position_history, + monthly_returns, + yearly_returns, + symbol_analysis, + drawdown_periods, + exposure_analysis, + ohlc_data: HashMap::new(), // Will be filled by orchestrator + error: None, + warnings: None, + } + } + + fn calculate_metrics(&self) -> BacktestMetrics { + let initial_capital = self.config.initial_capital; + let final_capital = self.equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital); + + // Core performance + let total_return = final_capital - initial_capital; + let total_return_pct = (total_return / initial_capital) * 100.0; + let days = (self.config.end_time - self.config.start_time).num_days() as f64; + let years = days / 365.25; + let annualized_return = if years > 0.0 { + ((final_capital / initial_capital).powf(1.0 / years) - 1.0) * 100.0 + } else { + 0.0 + }; + + // Trade statistics + let total_trades = self.trades.len(); + let winning_trades = self.trades.iter().filter(|t| t.pnl > 0.0).count(); + let losing_trades = self.trades.iter().filter(|t| t.pnl < 0.0).count(); + let breakeven_trades = self.trades.iter().filter(|t| t.pnl == 0.0).count(); + + let win_rate = if total_trades > 0 { + (winning_trades as f64 / total_trades as f64) * 100.0 + } else { + 0.0 + }; + + let loss_rate = if total_trades > 0 { + (losing_trades as f64 / total_trades as f64) * 100.0 + } else { + 0.0 + }; + + // PnL calculations + let total_pnl = self.trades.iter().map(|t| t.pnl).sum::(); + let total_commission = self.trades.iter().map(|t| t.commission).sum::(); + let net_pnl = total_pnl - total_commission; + + let wins: Vec = self.trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).collect(); + let losses: Vec = self.trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl).collect(); + + let avg_win = if !wins.is_empty() { + wins.iter().sum::() / wins.len() as f64 + } else { + 0.0 + }; + + let avg_loss = if !losses.is_empty() { + losses.iter().sum::() / losses.len() as f64 + } else { + 0.0 + }; + + let largest_win = wins.iter().cloned().fold(0.0, f64::max); + let largest_loss = losses.iter().cloned().fold(0.0, f64::min); + + let avg_trade_pnl = if total_trades > 0 { + total_pnl / total_trades as f64 + } else { + 0.0 + }; + + // Risk metrics + let returns = self.calculate_returns_series(); + let volatility = self.calculate_volatility(&returns); + let sharpe_ratio = self.calculate_sharpe_ratio(&returns, volatility); + let sortino_ratio = self.calculate_sortino_ratio(&returns); + let (max_drawdown, max_drawdown_pct, max_dd_duration) = self.calculate_max_drawdown(); + + let calmar_ratio = if max_drawdown_pct != 0.0 { + annualized_return / max_drawdown_pct.abs() + } else { + 0.0 + }; + + // Value at Risk + let (var_95, cvar_95) = self.calculate_var_cvar(&returns, 0.95); + + // Profit factor + let gross_profit = wins.iter().sum::(); + let gross_loss = losses.iter().map(|l| l.abs()).sum::(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Expectancy + let expectancy = avg_win * (win_rate / 100.0) - avg_loss.abs() * (loss_rate / 100.0); + let expectancy_ratio = if avg_loss != 0.0 { + expectancy / avg_loss.abs() + } else { + 0.0 + }; + + // Position statistics + let avg_trade_duration_hours = if total_trades > 0 { + self.trades.iter() + .map(|t| t.duration_seconds as f64 / 3600.0) + .sum::() / total_trades as f64 + } else { + 0.0 + }; + + let total_long_trades = self.trades.iter().filter(|t| matches!(t.side, Side::Buy)).count(); + let total_short_trades = self.trades.iter().filter(|t| matches!(t.side, Side::Sell)).count(); + + let long_wins = self.trades.iter() + .filter(|t| matches!(t.side, Side::Buy) && t.pnl > 0.0) + .count(); + let short_wins = self.trades.iter() + .filter(|t| matches!(t.side, Side::Sell) && t.pnl > 0.0) + .count(); + + let long_win_rate = if total_long_trades > 0 { + (long_wins as f64 / total_long_trades as f64) * 100.0 + } else { + 0.0 + }; + + let short_win_rate = if total_short_trades > 0 { + (short_wins as f64 / total_short_trades as f64) * 100.0 + } else { + 0.0 + }; + + // Trading activity + let total_volume_traded = self.trades.iter() + .map(|t| t.quantity * t.entry_price + t.quantity * t.exit_price) + .sum::(); + + let commission_pct_of_pnl = if total_pnl != 0.0 { + (total_commission / total_pnl.abs()) * 100.0 + } else { + 0.0 + }; + + // Calculate additional metrics + let downside_deviation = self.calculate_downside_deviation(&returns); + let win_loss_ratio = if avg_loss != 0.0 { + avg_win / avg_loss.abs() + } else { + 0.0 + }; + + let kelly_criterion = if win_loss_ratio > 0.0 { + (win_rate / 100.0) - ((1.0 - win_rate / 100.0) / win_loss_ratio) + } else { + 0.0 + }; + + // Streaks + let (max_wins, max_losses, current_streak) = self.calculate_streaks(); + + BacktestMetrics { + // Core Performance + total_return, + total_return_pct, + annualized_return, + total_pnl, + net_pnl, + + // Risk Metrics + sharpe_ratio, + sortino_ratio, + calmar_ratio, + max_drawdown, + max_drawdown_pct, + max_drawdown_duration_days: max_dd_duration, + volatility, + downside_deviation, + var_95, + cvar_95, + + // Trade Statistics + total_trades, + winning_trades, + losing_trades, + breakeven_trades, + win_rate, + loss_rate, + avg_win, + avg_loss, + largest_win, + largest_loss, + avg_trade_pnl, + avg_trade_duration_hours, + profit_factor, + expectancy, + expectancy_ratio, + + // Position Statistics + avg_position_size: 0.0, // TODO: Calculate from position history + max_position_size: 0.0, + avg_positions_held: 0.0, + max_concurrent_positions: 0, + total_long_trades, + total_short_trades, + long_win_rate, + short_win_rate, + + // Trading Activity + total_volume_traded, + total_commission_paid: total_commission, + commission_pct_of_pnl, + avg_daily_trades: 0.0, // TODO: Calculate + max_daily_trades: 0, + trading_days: 0, + exposure_time_pct: 0.0, + + // Efficiency Metrics + return_over_max_dd: if max_drawdown != 0.0 { + total_return / max_drawdown.abs() + } else { + 0.0 + }, + win_loss_ratio, + kelly_criterion, + ulcer_index: 0.0, // TODO: Calculate + serenity_ratio: 0.0, + lake_ratio: 0.0, + + // Monthly/Period Analysis + best_month_return: 0.0, // TODO: Calculate from monthly returns + worst_month_return: 0.0, + positive_months: 0, + negative_months: 0, + monthly_win_rate: 0.0, + avg_monthly_return: 0.0, + + // Streak Analysis + max_consecutive_wins: max_wins, + max_consecutive_losses: max_losses, + current_streak, + avg_winning_streak: 0.0, // TODO: Calculate + avg_losing_streak: 0.0, + } + } + + fn calculate_returns_series(&self) -> Vec { + let mut returns = Vec::new(); + for i in 1..self.equity_curve.len() { + let prev_value = self.equity_curve[i - 1].1; + let curr_value = self.equity_curve[i].1; + if prev_value > 0.0 { + returns.push((curr_value - prev_value) / prev_value); + } + } + returns + } + + fn calculate_volatility(&self, returns: &[f64]) -> f64 { + if returns.is_empty() { + return 0.0; + } + + let mean = returns.iter().sum::() / returns.len() as f64; + let variance = returns.iter() + .map(|r| (r - mean).powi(2)) + .sum::() / returns.len() as f64; + + // Annualize the volatility + variance.sqrt() * (252.0_f64).sqrt() + } + + fn calculate_sharpe_ratio(&self, returns: &[f64], volatility: f64) -> f64 { + if volatility == 0.0 || returns.is_empty() { + return 0.0; + } + + let mean_return = returns.iter().sum::() / returns.len() as f64; + let annualized_return = mean_return * 252.0; + let risk_free_rate = 0.02; // 2% annual risk-free rate + + (annualized_return - risk_free_rate) / volatility + } + + fn calculate_sortino_ratio(&self, returns: &[f64]) -> f64 { + if returns.is_empty() { + return 0.0; + } + + let mean_return = returns.iter().sum::() / returns.len() as f64; + let downside_deviation = self.calculate_downside_deviation(returns); + + if downside_deviation == 0.0 { + return 0.0; + } + + let annualized_return = mean_return * 252.0; + let risk_free_rate = 0.02; + + (annualized_return - risk_free_rate) / downside_deviation + } + + fn calculate_downside_deviation(&self, returns: &[f64]) -> f64 { + let negative_returns: Vec = returns.iter() + .filter(|&&r| r < 0.0) + .cloned() + .collect(); + + if negative_returns.is_empty() { + return 0.0; + } + + let mean = negative_returns.iter().sum::() / negative_returns.len() as f64; + let variance = negative_returns.iter() + .map(|r| (r - mean).powi(2)) + .sum::() / negative_returns.len() as f64; + + variance.sqrt() * (252.0_f64).sqrt() + } + + fn calculate_max_drawdown(&self) -> (f64, f64, i64) { + let mut max_drawdown = 0.0; + let mut max_drawdown_pct = 0.0; + let mut max_duration = 0i64; + let mut peak = self.config.initial_capital; + let mut peak_date = self.config.start_time; + + for (date, value) in &self.equity_curve { + if *value > peak { + peak = *value; + peak_date = *date; + } + + let drawdown = peak - value; + let drawdown_pct = (drawdown / peak) * 100.0; + + if drawdown > max_drawdown { + max_drawdown = drawdown; + max_drawdown_pct = drawdown_pct; + } + + if drawdown > 0.0 { + let duration = (*date - peak_date).num_days(); + if duration > max_duration { + max_duration = duration; + } + } + } + + (max_drawdown, max_drawdown_pct, max_duration) + } + + fn calculate_var_cvar(&self, returns: &[f64], confidence: f64) -> (f64, f64) { + if returns.is_empty() { + return (0.0, 0.0); + } + + let mut sorted_returns = returns.to_vec(); + sorted_returns.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + let index = ((1.0 - confidence) * sorted_returns.len() as f64) as usize; + let var = sorted_returns[index]; + + let cvar = sorted_returns[..=index].iter().sum::() / (index + 1) as f64; + + (var * 100.0, cvar * 100.0) + } + + fn calculate_streaks(&self) -> (usize, usize, i32) { + let mut max_wins = 0; + let mut max_losses = 0; + let mut current_streak = 0; + let mut current_wins = 0; + let mut current_losses = 0; + + for trade in &self.trades { + if trade.pnl > 0.0 { + current_wins += 1; + current_losses = 0; + if current_wins > max_wins { + max_wins = current_wins; + } + current_streak = current_wins as i32; + } else if trade.pnl < 0.0 { + current_losses += 1; + current_wins = 0; + if current_losses > max_losses { + max_losses = current_losses; + } + current_streak = -(current_losses as i32); + } + } + + (max_wins, max_losses, current_streak) + } + + fn calculate_drawdown_curve(&self) -> Vec { + let mut drawdown_curve = Vec::new(); + let mut peak = self.config.initial_capital; + + for (date, value) in &self.equity_curve { + if *value > peak { + peak = *value; + } + let drawdown_pct = ((peak - value) / peak) * 100.0; + drawdown_curve.push(EquityPoint { + timestamp: date.timestamp_millis(), + value: -drawdown_pct, // Negative for visualization + }); + } + + drawdown_curve + } + + fn calculate_daily_returns(&self) -> Vec { + let mut daily_returns = Vec::new(); + + for i in 1..self.equity_curve.len() { + let prev = &self.equity_curve[i - 1]; + let curr = &self.equity_curve[i]; + + // Only include if it's a new day + if prev.0.date() != curr.0.date() { + let return_pct = ((curr.1 - prev.1) / prev.1) * 100.0; + daily_returns.push(DailyReturn { + date: curr.0.format("%Y-%m-%d").to_string(), + value: return_pct, + }); + } + } + + daily_returns + } + + fn calculate_cumulative_returns(&self) -> Vec { + let initial = self.config.initial_capital; + self.equity_curve.iter() + .map(|(date, value)| EquityPoint { + timestamp: date.timestamp_millis(), + value: ((value - initial) / initial) * 100.0, + }) + .collect() + } + + fn convert_fills_to_trades(&self) -> Vec { + self.fills.iter() + .enumerate() + .map(|(i, fill)| Trade { + id: format!("trade-{}", i), + timestamp: fill.timestamp, + symbol: fill.symbol.clone(), + side: match fill.side { + Side::Buy => "buy".to_string(), + Side::Sell => "sell".to_string(), + }, + quantity: fill.quantity, + price: fill.price, + commission: fill.commission, + pnl: None, // Individual fills don't have PnL + }) + .collect() + } + + fn get_open_trades(&self) -> Vec { + // TODO: Implement based on position tracker + Vec::new() + } + + fn calculate_period_returns(&self, period: &str) -> Vec { + // TODO: Implement monthly/yearly return calculations + Vec::new() + } + + fn calculate_symbol_analysis(&self) -> HashMap { + let mut analysis: HashMap = HashMap::new(); + + // Group trades by symbol + for trade in &self.trades { + let entry = analysis.entry(trade.symbol.clone()).or_insert(SymbolAnalysis { + symbol: trade.symbol.clone(), + total_trades: 0, + winning_trades: 0, + losing_trades: 0, + total_pnl: 0.0, + win_rate: 0.0, + avg_win: 0.0, + avg_loss: 0.0, + profit_factor: 0.0, + total_volume: 0.0, + avg_position_size: 0.0, + exposure_time_pct: 0.0, + }); + + entry.total_trades += 1; + entry.total_pnl += trade.pnl; + entry.total_volume += trade.quantity * (trade.entry_price + trade.exit_price); + + if trade.pnl > 0.0 { + entry.winning_trades += 1; + } else if trade.pnl < 0.0 { + entry.losing_trades += 1; + } + } + + // Calculate derived metrics + for (_, entry) in analysis.iter_mut() { + if entry.total_trades > 0 { + entry.win_rate = (entry.winning_trades as f64 / entry.total_trades as f64) * 100.0; + entry.avg_position_size = entry.total_volume / (entry.total_trades as f64 * 2.0); + } + + // Calculate avg win/loss + let wins: Vec = self.trades.iter() + .filter(|t| t.symbol == entry.symbol && t.pnl > 0.0) + .map(|t| t.pnl) + .collect(); + let losses: Vec = self.trades.iter() + .filter(|t| t.symbol == entry.symbol && t.pnl < 0.0) + .map(|t| t.pnl) + .collect(); + + if !wins.is_empty() { + entry.avg_win = wins.iter().sum::() / wins.len() as f64; + } + if !losses.is_empty() { + entry.avg_loss = losses.iter().sum::() / losses.len() as f64; + } + + // Profit factor + let gross_profit = wins.iter().sum::(); + let gross_loss = losses.iter().map(|l| l.abs()).sum::(); + if gross_loss > 0.0 { + entry.profit_factor = gross_profit / gross_loss; + } else if gross_profit > 0.0 { + entry.profit_factor = f64::INFINITY; + } + } + + analysis + } + + fn analyze_drawdown_periods(&self) -> Vec { + // TODO: Implement comprehensive drawdown period analysis + Vec::new() + } + + fn calculate_exposure_analysis(&self) -> ExposureAnalysis { + // TODO: Implement exposure analysis + ExposureAnalysis { + avg_gross_exposure: 0.0, + avg_net_exposure: 0.0, + max_gross_exposure: 0.0, + max_net_exposure: 0.0, + time_in_market_pct: 0.0, + long_exposure_pct: 0.0, + short_exposure_pct: 0.0, + } + } + + fn calculate_position_history(&self) -> Vec { + // TODO: Implement position history tracking + Vec::new() + } + + fn extract_trade_signals(&self) -> Vec { + // TODO: Extract signals from strategy execution + Vec::new() + } +} \ No newline at end of file diff --git a/apps/stock/core/src/backtest/mod.rs b/apps/stock/core/src/backtest/mod.rs index dc999ad..4e2d404 100644 --- a/apps/stock/core/src/backtest/mod.rs +++ b/apps/stock/core/src/backtest/mod.rs @@ -11,13 +11,13 @@ use serde::{Serialize, Deserialize}; pub mod engine; pub mod event; pub mod strategy; -pub mod results; +pub mod simple_results; pub mod trade_tracker; pub use engine::BacktestEngine; pub use event::{BacktestEvent, EventType}; pub use strategy::{Strategy, Signal, SignalType}; -pub use results::{BacktestResult, BacktestMetrics}; +pub use simple_results::{BacktestResult, BacktestMetrics}; pub use trade_tracker::{TradeTracker, CompletedTrade as TrackedTrade}; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/apps/stock/core/src/backtest/results.rs b/apps/stock/core/src/backtest/results.rs index d5e45dd..26cfd33 100644 --- a/apps/stock/core/src/backtest/results.rs +++ b/apps/stock/core/src/backtest/results.rs @@ -5,24 +5,262 @@ use crate::Position; use super::{BacktestConfig, CompletedTrade}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct BacktestMetrics { + // Core Performance Metrics pub total_return: f64, - pub total_trades: usize, - pub profitable_trades: usize, - pub win_rate: f64, - pub profit_factor: f64, - pub sharpe_ratio: f64, - pub max_drawdown: f64, + pub total_return_pct: f64, + pub annualized_return: f64, pub total_pnl: f64, + pub net_pnl: f64, // After commissions + + // Risk Metrics + pub sharpe_ratio: f64, + pub sortino_ratio: f64, + pub calmar_ratio: f64, + pub max_drawdown: f64, + pub max_drawdown_pct: f64, + pub max_drawdown_duration_days: i64, + pub volatility: f64, + pub downside_deviation: f64, + pub var_95: f64, // Value at Risk 95% + pub cvar_95: f64, // Conditional VaR 95% + + // Trade Statistics + pub total_trades: usize, + pub winning_trades: usize, + pub losing_trades: usize, + pub breakeven_trades: usize, + pub win_rate: f64, + pub loss_rate: f64, pub avg_win: f64, pub avg_loss: f64, + pub largest_win: f64, + pub largest_loss: f64, + pub avg_trade_pnl: f64, + pub avg_trade_duration_hours: f64, + pub profit_factor: f64, + pub expectancy: f64, + pub expectancy_ratio: f64, + + // Position Statistics + pub avg_position_size: f64, + pub max_position_size: f64, + pub avg_positions_held: f64, + pub max_concurrent_positions: usize, + pub total_long_trades: usize, + pub total_short_trades: usize, + pub long_win_rate: f64, + pub short_win_rate: f64, + + // Trading Activity + pub total_volume_traded: f64, + pub total_commission_paid: f64, + pub commission_pct_of_pnl: f64, + pub avg_daily_trades: f64, + pub max_daily_trades: usize, + pub trading_days: usize, + pub exposure_time_pct: f64, + + // Efficiency Metrics + pub return_over_max_dd: f64, + pub win_loss_ratio: f64, + pub kelly_criterion: f64, + pub ulcer_index: f64, + pub serenity_ratio: f64, + pub lake_ratio: f64, + + // Monthly/Period Analysis + pub best_month_return: f64, + pub worst_month_return: f64, + pub positive_months: usize, + pub negative_months: usize, + pub monthly_win_rate: f64, + pub avg_monthly_return: f64, + + // Streak Analysis + pub max_consecutive_wins: usize, + pub max_consecutive_losses: usize, + pub current_streak: i32, // Positive for wins, negative for losses + pub avg_winning_streak: f64, + pub avg_losing_streak: f64, +} + +// Trade structure that matches web app expectations +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trade { + pub id: String, + pub timestamp: DateTime, + pub symbol: String, + pub side: String, // "buy" or "sell" + pub quantity: f64, + pub price: f64, + pub commission: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub pnl: Option, +} + +// Equity curve point +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EquityPoint { + pub timestamp: i64, // milliseconds since epoch + pub value: f64, +} + +// Daily return data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyReturn { + pub date: String, + pub value: f64, +} + +// Performance data point for charts +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerformanceDataPoint { + pub timestamp: String, + pub portfolio_value: f64, + pub pnl: f64, + pub drawdown: f64, +} + +// Period returns (monthly, weekly, etc) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PeriodReturn { + pub period: String, // "2024-01", "2024-W01", etc + pub start_date: String, + pub end_date: String, + pub return_pct: f64, + pub pnl: f64, + pub trades: usize, + pub win_rate: f64, +} + +// Trade analysis by symbol +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolAnalysis { + pub symbol: String, + pub total_trades: usize, + pub winning_trades: usize, + pub losing_trades: usize, + pub total_pnl: f64, + pub win_rate: f64, + pub avg_win: f64, + pub avg_loss: f64, + pub profit_factor: f64, + pub total_volume: f64, + pub avg_position_size: f64, + pub exposure_time_pct: f64, +} + +// Drawdown period information +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawdownPeriod { + pub start_date: String, + pub end_date: String, + pub peak_value: f64, + pub trough_value: f64, + pub drawdown_pct: f64, + pub recovery_date: Option, + pub duration_days: i64, + pub recovery_days: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct BacktestResult { + // Metadata + pub backtest_id: String, + pub status: String, + pub started_at: String, + pub completed_at: String, + pub execution_time_ms: u64, + + // Configuration pub config: BacktestConfig, + + // Core Metrics pub metrics: BacktestMetrics, - pub equity_curve: Vec<(DateTime, f64)>, - pub trades: Vec, - pub final_positions: HashMap, + + // Time Series Data + pub equity_curve: Vec, + pub drawdown_curve: Vec, + pub daily_returns: Vec, + pub cumulative_returns: Vec, + + // Trade Data + pub trades: Vec, + pub open_trades: Vec, + pub trade_signals: Vec, + + // Position Data + pub positions: HashMap, + pub position_history: Vec, + + // Analytics + pub monthly_returns: Vec, + pub yearly_returns: Vec, + pub symbol_analysis: HashMap, + pub drawdown_periods: Vec, + pub exposure_analysis: ExposureAnalysis, + + // Market Data + pub ohlc_data: HashMap>, + + // Error Handling + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub warnings: Option>, +} + +// Trade signal for visualization +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TradeSignal { + pub timestamp: String, + pub symbol: String, + pub action: String, // "buy", "sell", "hold" + pub strength: f64, + pub reason: String, +} + +// Position snapshot for tracking +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PositionSnapshot { + pub timestamp: String, + pub positions: HashMap, + pub total_value: f64, + pub cash: f64, + pub margin_used: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PositionDetail { + pub symbol: String, + pub quantity: f64, + pub avg_price: f64, + pub current_price: f64, + pub market_value: f64, + pub unrealized_pnl: f64, + pub unrealized_pnl_pct: f64, +} + +// Exposure analysis +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExposureAnalysis { + pub avg_gross_exposure: f64, + pub avg_net_exposure: f64, + pub max_gross_exposure: f64, + pub max_net_exposure: f64, + pub time_in_market_pct: f64, + pub long_exposure_pct: f64, + pub short_exposure_pct: f64, } \ No newline at end of file diff --git a/apps/stock/core/src/backtest/simple_results.rs b/apps/stock/core/src/backtest/simple_results.rs new file mode 100644 index 0000000..3059ecf --- /dev/null +++ b/apps/stock/core/src/backtest/simple_results.rs @@ -0,0 +1,551 @@ +use chrono::{DateTime, Utc, Datelike}; +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use crate::Position; +use super::{BacktestConfig, CompletedTrade}; +use super::trade_tracker::CompletedTrade as TrackedTrade; + +// Simplified metrics that match what the web app expects +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BacktestMetrics { + pub total_return: f64, + pub sharpe_ratio: f64, + pub max_drawdown: f64, + pub win_rate: f64, + pub total_trades: usize, + pub profitable_trades: usize, + // Additional fields for compatibility + pub profit_factor: f64, + pub total_pnl: f64, + pub avg_win: f64, + pub avg_loss: f64, + pub expectancy: f64, + pub calmar_ratio: f64, + pub sortino_ratio: f64, +} + +// Individual trade (fill) structure for UI +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trade { + pub id: String, + pub timestamp: String, + pub symbol: String, + pub side: String, // "buy" or "sell" + pub quantity: f64, + pub price: f64, + pub commission: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub pnl: Option, +} + +// Analytics data structure +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Analytics { + pub drawdown_series: Vec, + pub daily_returns: Vec, + pub monthly_returns: HashMap, + pub exposure_time: f64, + pub risk_metrics: HashMap, +} + +// Position structure for web app +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PositionInfo { + pub symbol: String, + pub quantity: f64, + pub average_price: f64, + pub current_price: f64, + pub unrealized_pnl: f64, + pub realized_pnl: f64, +} + +// Equity data point for charts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EquityDataPoint { + pub date: String, + pub value: f64, +} + +// Simplified backtest result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BacktestResult { + pub backtest_id: String, + pub status: String, + pub completed_at: String, + pub config: BacktestConfig, + pub metrics: BacktestMetrics, + pub equity: Vec, + pub trades: Vec, + pub positions: Vec, + pub analytics: Analytics, + pub execution_time: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub ohlc_data: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EquityPoint { + pub timestamp: i64, + pub value: f64, +} + +// Trade structure that web app expects (with entry/exit info) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletedTradeInfo { + pub id: String, + pub symbol: String, + pub entry_date: String, + pub exit_date: Option, + pub entry_price: f64, + pub exit_price: f64, + pub quantity: f64, + pub side: String, + pub pnl: f64, + pub pnl_percent: f64, + pub commission: f64, + pub duration: i64, // milliseconds +} + +impl BacktestResult { + pub fn from_engine_data( + config: BacktestConfig, + equity_curve: Vec<(DateTime, f64)>, + fills: Vec, + final_positions: HashMap, + start_time: DateTime, + ) -> Self { + let initial_capital = config.initial_capital; + let final_value = equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital); + + // Convert fills to trades + let trades: Vec = fills.iter() + .enumerate() + .map(|(i, fill)| Trade { + id: format!("trade-{}", i), + timestamp: fill.timestamp.to_rfc3339(), + symbol: fill.symbol.clone(), + side: match fill.side { + crate::Side::Buy => "buy".to_string(), + crate::Side::Sell => "sell".to_string(), + }, + quantity: fill.quantity, + price: fill.price, + commission: fill.commission, + pnl: None, + }) + .collect(); + + // Calculate metrics + let total_trades = trades.len(); + let total_return = ((final_value - initial_capital) / initial_capital) * 100.0; + let total_pnl = final_value - initial_capital; + + // Basic win rate calculation - this is simplified + let profitable_trades = 0; // TODO: Calculate from paired trades + let win_rate = 0.0; // TODO: Calculate properly + + // Calculate daily returns + let mut daily_returns_vec = Vec::new(); + let mut prev_date = equity_curve.first().map(|(d, _)| d.date_naive()).unwrap_or_default(); + let mut prev_value = initial_capital; + + for (dt, value) in &equity_curve { + if dt.date_naive() != prev_date { + let daily_return = (value - prev_value) / prev_value; + daily_returns_vec.push(daily_return); + prev_date = dt.date_naive(); + prev_value = *value; + } + } + + // Calculate volatility (annualized) + let volatility = if daily_returns_vec.len() > 1 { + let mean = daily_returns_vec.iter().sum::() / daily_returns_vec.len() as f64; + let variance = daily_returns_vec.iter() + .map(|r| (r - mean).powi(2)) + .sum::() / daily_returns_vec.len() as f64; + variance.sqrt() * (252.0_f64).sqrt() + } else { + 0.0 + }; + + // Calculate Sharpe ratio + let risk_free_rate = 0.02; // 2% annual + let days = (config.end_time - config.start_time).num_days() as f64; + let years = days / 365.25; + let annualized_return = if years > 0.0 { + (final_value / initial_capital).powf(1.0 / years) - 1.0 + } else { + 0.0 + }; + let sharpe_ratio = if volatility > 0.0 { + (annualized_return - risk_free_rate) / volatility + } else { + 0.0 + }; + + // Calculate max drawdown + let (max_drawdown, drawdown_series) = Self::calculate_drawdown(&equity_curve, initial_capital); + + // Calculate Calmar ratio + let calmar_ratio = if max_drawdown > 0.0 { + annualized_return / max_drawdown + } else { + 0.0 + }; + + // Convert positions + let positions: Vec = final_positions.iter() + .map(|(symbol, pos)| { + let current_price = pos.average_price; // TODO: Get actual current price + let unrealized_pnl = (current_price - pos.average_price) * pos.quantity; + PositionInfo { + symbol: symbol.clone(), + quantity: pos.quantity, + average_price: pos.average_price, + current_price, + unrealized_pnl, + realized_pnl: pos.realized_pnl, + } + }) + .collect(); + + // Convert equity curve to the format expected by web app + let equity: Vec = equity_curve.iter() + .map(|(dt, val)| EquityDataPoint { + date: dt.to_rfc3339(), + value: *val, + }) + .collect(); + + let metrics = BacktestMetrics { + total_return, + sharpe_ratio, + max_drawdown, + win_rate, + total_trades, + profitable_trades, + profit_factor: 0.0, + total_pnl, + avg_win: 0.0, + avg_loss: 0.0, + expectancy: 0.0, + calmar_ratio, + sortino_ratio: 0.0, // TODO: Calculate + }; + + // Create analytics + let analytics = Analytics { + drawdown_series, + daily_returns: daily_returns_vec, + monthly_returns: HashMap::new(), // TODO: Calculate + exposure_time: 0.0, // TODO: Calculate + risk_metrics: HashMap::new(), // TODO: Add risk metrics + }; + + BacktestResult { + backtest_id: format!("rust-{}", Utc::now().timestamp_millis()), + status: "completed".to_string(), + completed_at: Utc::now().to_rfc3339(), + config, + metrics, + equity, + trades: Vec::new(), // No completed trades in simple version + positions, + analytics, + execution_time: (Utc::now() - start_time).num_milliseconds() as u64, + error: None, + ohlc_data: HashMap::new(), + } + } + + fn calculate_drawdown(equity_curve: &[(DateTime, f64)], initial_capital: f64) -> (f64, Vec) { + let mut max_drawdown = 0.0; + let mut peak = initial_capital; + let mut drawdown_series = Vec::new(); + + for (dt, value) in equity_curve { + if *value > peak { + peak = *value; + } + let drawdown_pct = if peak > 0.0 { + ((peak - value) / peak) * 100.0 + } else { + 0.0 + }; + + if drawdown_pct > max_drawdown { + max_drawdown = drawdown_pct; + } + + drawdown_series.push(EquityPoint { + timestamp: dt.timestamp_millis(), + value: -drawdown_pct, // Negative for visualization + }); + } + + (max_drawdown / 100.0, drawdown_series) // Return as decimal + } + + pub fn from_engine_data_with_trades( + config: BacktestConfig, + equity_curve: Vec<(DateTime, f64)>, + fills: Vec, + completed_trades: Vec, + final_positions: HashMap, + start_time: DateTime, + last_prices: &HashMap, + ) -> Self { + let initial_capital = config.initial_capital; + let final_value = equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital); + + // Convert completed trades to web app format + let trades: Vec = completed_trades.iter() + .map(|trade| CompletedTradeInfo { + id: trade.id.clone(), + symbol: trade.symbol.clone(), + entry_date: trade.entry_time.to_rfc3339(), + exit_date: Some(trade.exit_time.to_rfc3339()), + entry_price: trade.entry_price, + exit_price: trade.exit_price, + quantity: trade.quantity, + side: match trade.side { + crate::Side::Buy => "buy".to_string(), + crate::Side::Sell => "sell".to_string(), + }, + pnl: trade.pnl, + pnl_percent: trade.pnl_percent, + commission: trade.commission, + duration: trade.duration_seconds * 1000, // Convert to milliseconds + }) + .collect(); + + // Calculate metrics from completed trades + let total_trades = completed_trades.len(); + let total_return = ((final_value - initial_capital) / initial_capital) * 100.0; + let total_pnl = final_value - initial_capital; + + // Calculate win rate and profit metrics + let winning_trades: Vec<&TrackedTrade> = completed_trades.iter() + .filter(|t| t.pnl > 0.0) + .collect(); + let losing_trades: Vec<&TrackedTrade> = completed_trades.iter() + .filter(|t| t.pnl < 0.0) + .collect(); + + let profitable_trades = winning_trades.len(); + let win_rate = if total_trades > 0 { + (profitable_trades as f64 / total_trades as f64) * 100.0 + } else { + 0.0 + }; + + let avg_win = if !winning_trades.is_empty() { + winning_trades.iter().map(|t| t.pnl).sum::() / winning_trades.len() as f64 + } else { + 0.0 + }; + + let avg_loss = if !losing_trades.is_empty() { + losing_trades.iter().map(|t| t.pnl).sum::() / losing_trades.len() as f64 + } else { + 0.0 + }; + + let gross_profit = winning_trades.iter().map(|t| t.pnl).sum::(); + let gross_loss = losing_trades.iter().map(|t| t.pnl.abs()).sum::(); + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + let expectancy = avg_win * (win_rate / 100.0) - avg_loss.abs() * ((100.0 - win_rate) / 100.0); + + // Calculate daily returns + let mut daily_returns_vec = Vec::new(); + let mut prev_date = equity_curve.first().map(|(d, _)| d.date_naive()).unwrap_or_default(); + let mut prev_value = initial_capital; + + for (dt, value) in &equity_curve { + if dt.date_naive() != prev_date { + let daily_return = (value - prev_value) / prev_value; + daily_returns_vec.push(daily_return); + prev_date = dt.date_naive(); + prev_value = *value; + } + } + + // Calculate volatility (annualized) + let volatility = if daily_returns_vec.len() > 1 { + let mean = daily_returns_vec.iter().sum::() / daily_returns_vec.len() as f64; + let variance = daily_returns_vec.iter() + .map(|r| (r - mean).powi(2)) + .sum::() / daily_returns_vec.len() as f64; + variance.sqrt() * (252.0_f64).sqrt() + } else { + 0.0 + }; + + // Calculate Sharpe ratio + let risk_free_rate = 0.02; // 2% annual + let days = (config.end_time - config.start_time).num_days() as f64; + let years = days / 365.25; + let annualized_return = if years > 0.0 { + (final_value / initial_capital).powf(1.0 / years) - 1.0 + } else { + 0.0 + }; + let sharpe_ratio = if volatility > 0.0 { + (annualized_return - risk_free_rate) / volatility + } else { + 0.0 + }; + + // Calculate Sortino ratio (using downside deviation) + let negative_returns: Vec = daily_returns_vec.iter() + .filter(|&&r| r < 0.0) + .cloned() + .collect(); + let downside_deviation = if !negative_returns.is_empty() { + let mean = negative_returns.iter().sum::() / negative_returns.len() as f64; + let variance = negative_returns.iter() + .map(|r| (r - mean).powi(2)) + .sum::() / negative_returns.len() as f64; + variance.sqrt() * (252.0_f64).sqrt() + } else { + 0.0 + }; + let sortino_ratio = if downside_deviation > 0.0 { + (annualized_return - risk_free_rate) / downside_deviation + } else { + 0.0 + }; + + // Calculate max drawdown + let (max_drawdown, drawdown_series) = Self::calculate_drawdown(&equity_curve, initial_capital); + + // Calculate Calmar ratio + let calmar_ratio = if max_drawdown > 0.0 { + annualized_return / max_drawdown + } else { + 0.0 + }; + + // Convert positions with actual last prices + let positions: Vec = final_positions.iter() + .map(|(symbol, pos)| { + let current_price = last_prices.get(symbol).copied() + .unwrap_or(pos.average_price); + let unrealized_pnl = (current_price - pos.average_price) * pos.quantity; + PositionInfo { + symbol: symbol.clone(), + quantity: pos.quantity, + average_price: pos.average_price, + current_price, + unrealized_pnl, + realized_pnl: pos.realized_pnl, + } + }) + .collect(); + + // Convert equity curve to the format expected by web app + let equity: Vec = equity_curve.iter() + .map(|(dt, val)| EquityDataPoint { + date: dt.to_rfc3339(), + value: *val, + }) + .collect(); + + // Convert individual fills to UI format (for backward compatibility) + let _fill_trades: Vec = fills.iter() + .enumerate() + .map(|(i, fill)| Trade { + id: format!("fill-{}", i), + timestamp: fill.timestamp.to_rfc3339(), + symbol: fill.symbol.clone(), + side: match fill.side { + crate::Side::Buy => "buy".to_string(), + crate::Side::Sell => "sell".to_string(), + }, + quantity: fill.quantity, + price: fill.price, + commission: fill.commission, + pnl: None, + }) + .collect(); + + let metrics = BacktestMetrics { + total_return, + sharpe_ratio, + max_drawdown, + win_rate, + total_trades, + profitable_trades, + profit_factor, + total_pnl, + avg_win, + avg_loss, + expectancy, + calmar_ratio, + sortino_ratio, + }; + + // Calculate monthly returns + let mut monthly_returns = HashMap::new(); + let mut monthly_values: HashMap> = HashMap::new(); + + for (dt, value) in &equity_curve { + let month_key = format!("{}-{:02}", dt.year(), dt.month()); + monthly_values.entry(month_key).or_insert_with(Vec::new).push(*value); + } + + for (month, values) in monthly_values { + if values.len() >= 2 { + let start = values.first().unwrap(); + let end = values.last().unwrap(); + let monthly_return = ((end - start) / start) * 100.0; + monthly_returns.insert(month, monthly_return); + } + } + + // Create analytics + let analytics = Analytics { + drawdown_series, + daily_returns: daily_returns_vec, + monthly_returns, + exposure_time: 0.0, // TODO: Calculate based on position history + risk_metrics: { + let mut metrics = HashMap::new(); + metrics.insert("volatility".to_string(), volatility); + metrics.insert("downside_deviation".to_string(), downside_deviation); + metrics.insert("value_at_risk_95".to_string(), 0.0); // TODO: Calculate VaR + metrics + }, + }; + + BacktestResult { + backtest_id: format!("rust-{}", Utc::now().timestamp_millis()), + status: "completed".to_string(), + completed_at: Utc::now().to_rfc3339(), + config, + metrics, + equity, + trades, // Return the completed trades + positions, + analytics, + execution_time: (Utc::now() - start_time).num_milliseconds() as u64, + error: None, + ohlc_data: HashMap::new(), + } + } +} \ No newline at end of file diff --git a/apps/stock/core/src/backtest/trade_tracker.rs b/apps/stock/core/src/backtest/trade_tracker.rs index a4a20c3..654c281 100644 --- a/apps/stock/core/src/backtest/trade_tracker.rs +++ b/apps/stock/core/src/backtest/trade_tracker.rs @@ -44,6 +44,10 @@ impl TradeTracker { trade_counter: 0, } } + + pub fn get_completed_trades(&self) -> Vec { + self.completed_trades.clone() + } pub fn process_fill(&mut self, symbol: &str, side: Side, fill: &Fill) { let positions = self.open_positions.entry(symbol.to_string()).or_insert_with(VecDeque::new); @@ -135,9 +139,6 @@ impl TradeTracker { (net_pnl, pnl_percent) } - pub fn get_completed_trades(&self) -> &[CompletedTrade] { - &self.completed_trades - } pub fn get_open_positions(&self) -> HashMap> { let mut result = HashMap::new(); diff --git a/apps/stock/orchestrator/src/backtest/RustBacktestAdapter.ts b/apps/stock/orchestrator/src/backtest/RustBacktestAdapter.ts index b70b7a6..e1ce51f 100644 --- a/apps/stock/orchestrator/src/backtest/RustBacktestAdapter.ts +++ b/apps/stock/orchestrator/src/backtest/RustBacktestAdapter.ts @@ -86,6 +86,11 @@ export class RustBacktestAdapter extends EventEmitter { tradesLength: rustResult.trades?.length, finalPositions: rustResult.final_positions }); + + // Log first trade structure to understand format + if (rustResult.trades?.length > 0) { + this.container.logger.info('First trade structure:', rustResult.trades[0]); + } // Store OHLC data for each symbol const ohlcData: Record = {}; @@ -106,44 +111,9 @@ export class RustBacktestAdapter extends EventEmitter { })); } - // Convert Rust result to orchestrator format + // Rust result is already in the correct format, just add OHLC data const result: BacktestResult = { - backtestId: `rust-${Date.now()}`, - status: 'completed', - completedAt: new Date().toISOString(), - config: { - name: config.name || 'Backtest', - strategy: config.strategy, - symbols: config.symbols, - startDate: config.startDate, - endDate: config.endDate, - initialCapital: config.initialCapital, - commission: config.commission || 0.001, - slippage: config.slippage || 0.0001, - dataFrequency: config.dataFrequency || '1d', - }, - metrics: { - totalReturn: rustResult.metrics.total_return, - sharpeRatio: rustResult.metrics.sharpe_ratio, - maxDrawdown: rustResult.metrics.max_drawdown, - winRate: rustResult.metrics.win_rate, - totalTrades: rustResult.metrics.total_trades, - profitFactor: rustResult.metrics.profit_factor, - profitableTrades: rustResult.metrics.profitable_trades, - avgWin: rustResult.metrics.avg_win, - avgLoss: rustResult.metrics.avg_loss, - expectancy: this.calculateExpectancy(rustResult.metrics), - calmarRatio: rustResult.metrics.total_return / (rustResult.metrics.max_drawdown || 1), - sortinoRatio: 0, // TODO: Calculate from downside deviation - }, - equityCurve: rustResult.equity_curve.map((point: any) => ({ - timestamp: new Date(point[0]).getTime(), - value: point[1], - })), - trades: this.transformCompletedTradesToFills(rustResult.trades || []), - dailyReturns: this.calculateDailyReturns(rustResult.equity_curve), - finalPositions: rustResult.final_positions || {}, - executionTime: Date.now() - startTime, + ...rustResult, ohlcData, }; @@ -315,208 +285,4 @@ export class RustBacktestAdapter extends EventEmitter { } } - private calculateExpectancy(metrics: any): number { - if (metrics.total_trades === 0) return 0; - - const winProb = metrics.win_rate / 100; - const lossProb = 1 - winProb; - - return (winProb * metrics.avg_win) - (lossProb * Math.abs(metrics.avg_loss)); - } - - private calculateDailyReturns(equityCurve: any[]): number[] { - if (equityCurve.length < 2) return []; - - const returns: number[] = []; - for (let i = 1; i < equityCurve.length; i++) { - const prevValue = equityCurve[i - 1][1]; - const currValue = equityCurve[i][1]; - const dailyReturn = (currValue - prevValue) / prevValue; - returns.push(dailyReturn); - } - - return returns; - } - - private getEmptyMetrics() { - return { - totalReturn: 0, - sharpeRatio: 0, - maxDrawdown: 0, - winRate: 0, - totalTrades: 0, - profitFactor: 0, - profitableTrades: 0, - avgWin: 0, - avgLoss: 0, - expectancy: 0, - calmarRatio: 0, - sortinoRatio: 0, - }; - } - - private transformCompletedTradesToFills(completedTrades: any[]): any[] { - // Convert completed trades (paired entry/exit) back to individual fills for the UI - const fills: any[] = []; - let fillId = 0; - - completedTrades.forEach(trade => { - // Create entry fill - fills.push({ - id: `fill-${fillId++}`, - timestamp: trade.entry_time || trade.entryDate, - symbol: trade.symbol, - side: trade.side === 'Buy' || trade.side === 'long' ? 'buy' : 'sell', - quantity: trade.quantity, - price: trade.entry_price || trade.entryPrice, - commission: trade.commission / 2, // Split commission between entry and exit - }); - - // Create exit fill (opposite side) - const exitSide = (trade.side === 'Buy' || trade.side === 'long') ? 'sell' : 'buy'; - fills.push({ - id: `fill-${fillId++}`, - timestamp: trade.exit_time || trade.exitDate, - symbol: trade.symbol, - side: exitSide, - quantity: trade.quantity, - price: trade.exit_price || trade.exitPrice, - commission: trade.commission / 2, - pnl: trade.pnl, - }); - }); - - // Sort by timestamp - fills.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); - - return fills; - } - - private transformFillsToTrades(completedTrades: any[]): any[] { - // Group fills by symbol to match entries with exits - const fillsBySymbol: { [symbol: string]: any[] } = {}; - - completedTrades.forEach(trade => { - if (!fillsBySymbol[trade.symbol]) { - fillsBySymbol[trade.symbol] = []; - } - fillsBySymbol[trade.symbol].push(trade); - }); - - const pairedTrades: any[] = []; - const openPositions: { [symbol: string]: any[] } = {}; - - // Process each symbol's fills chronologically - Object.entries(fillsBySymbol).forEach(([symbol, fills]) => { - // Sort by timestamp - fills.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); - - fills.forEach((fill, idx) => { - const isBuy = fill.side === 'Buy'; - const timestamp = new Date(fill.timestamp); - - if (!openPositions[symbol]) { - openPositions[symbol] = []; - } - - const openPos = openPositions[symbol]; - - // For buy fills, add to open positions - if (isBuy) { - openPos.push(fill); - } else { - // For sell fills, match with open buy positions (FIFO) - if (openPos.length > 0 && openPos[0].side === 'Buy') { - const entry = openPos.shift(); - const entryDate = new Date(entry.timestamp); - const duration = (timestamp.getTime() - entryDate.getTime()) / 1000; // seconds - const pnl = (fill.price - entry.price) * fill.quantity - entry.commission - fill.commission; - const pnlPercent = ((fill.price - entry.price) / entry.price) * 100; - - pairedTrades.push({ - id: `trade-${pairedTrades.length}`, - symbol, - entryDate: entryDate.toISOString(), - exitDate: timestamp.toISOString(), - entryPrice: entry.price, - exitPrice: fill.price, - quantity: fill.quantity, - side: 'long', - pnl, - pnlPercent, - commission: entry.commission + fill.commission, - duration, - }); - } else { - // This is a short entry - openPos.push(fill); - } - } - - // For short positions (sell first, then buy to cover) - if (!isBuy && openPos.length > 0) { - const lastPos = openPos[openPos.length - 1]; - if (lastPos.side === 'Sell' && idx < fills.length - 1) { - const nextFill = fills[idx + 1]; - if (nextFill && nextFill.side === 'Buy') { - // We'll handle this when we process the buy fill - } - } - } - - // Handle buy fills that close short positions - if (isBuy && openPos.length > 1) { - const shortPos = openPos.find(p => p.side === 'Sell'); - if (shortPos) { - const shortIdx = openPos.indexOf(shortPos); - openPos.splice(shortIdx, 1); - - const entryDate = new Date(shortPos.timestamp); - const duration = (timestamp.getTime() - entryDate.getTime()) / 1000; - const pnl = (shortPos.price - fill.price) * fill.quantity - shortPos.commission - fill.commission; - const pnlPercent = ((shortPos.price - fill.price) / shortPos.price) * 100; - - pairedTrades.push({ - id: `trade-${pairedTrades.length}`, - symbol, - entryDate: entryDate.toISOString(), - exitDate: timestamp.toISOString(), - entryPrice: shortPos.price, - exitPrice: fill.price, - quantity: fill.quantity, - side: 'short', - pnl, - pnlPercent, - commission: shortPos.commission + fill.commission, - duration, - }); - } - } - }); - - // Add any remaining open positions as incomplete trades - const remainingOpenPositions = openPositions[symbol] || []; - remainingOpenPositions.forEach(pos => { - const timestamp = new Date(pos.timestamp); - const side = pos.side === 'Buy' ? 'buy' : 'sell'; - - pairedTrades.push({ - id: `trade-${pairedTrades.length}`, - symbol, - entryDate: timestamp.toISOString(), - exitDate: timestamp.toISOString(), // Same as entry for open positions - entryPrice: pos.price, - exitPrice: pos.price, - quantity: pos.quantity, - side, - pnl: 0, // No PnL for open positions - pnlPercent: 0, - commission: pos.commission, - duration: 0, - }); - }); - }); - - return pairedTrades; - } } \ No newline at end of file diff --git a/apps/stock/orchestrator/test-mean-reversion.ts b/apps/stock/orchestrator/test-mean-reversion.ts index 7496b5f..ba85313 100644 --- a/apps/stock/orchestrator/test-mean-reversion.ts +++ b/apps/stock/orchestrator/test-mean-reversion.ts @@ -150,20 +150,27 @@ async function testMeanReversionBacktest() { // Show first few trades console.log(` - First 3 trades:`); trades.slice(0, 3).forEach((trade, idx) => { - console.log(` ${idx + 1}. ${trade.side} - Price: $${trade.price.toFixed(2)}, Quantity: ${trade.quantity}${trade.pnl ? `, PnL: $${trade.pnl.toFixed(2)}` : ''}`); + console.log(` ${idx + 1}. Trade:`, JSON.stringify(trade, null, 2)); }); }); - // Check position distribution - const allDurations = result.trades.map(t => t.duration / 86400); // Convert to days - const avgDuration = allDurations.reduce((a, b) => a + b, 0) / allDurations.length; - const minDuration = Math.min(...allDurations); - const maxDuration = Math.max(...allDurations); + // Analyze trade pairing + console.log('\n=== Trade Pairing Analysis ==='); + console.log(`Total fills: ${result.trades.length}`); + console.log(`Expected pairs: ${result.trades.length / 2}`); - console.log('\n=== Duration Analysis ==='); - console.log(`Average trade duration: ${avgDuration.toFixed(1)} days`); - console.log(`Min duration: ${minDuration.toFixed(1)} days`); - console.log(`Max duration: ${maxDuration.toFixed(1)} days`); + // Look for patterns that show instant buy/sell + let instantPairs = 0; + for (let i = 1; i < result.trades.length; i++) { + const prev = result.trades[i-1]; + const curr = result.trades[i]; + if (prev.symbol === curr.symbol && + prev.side === 'buy' && curr.side === 'sell' && + new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime() < 86400000) { + instantPairs++; + } + } + console.log(`Instant buy/sell pairs (< 1 day): ${instantPairs}`); // Final positions console.log('\n=== Final Positions ==='); diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx index 9337690..d06076a 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx @@ -81,29 +81,29 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
= 0 ? '+' : ''}${results.metrics.totalReturn.toFixed(2)}%`} - trend={results.metrics.totalReturn >= 0 ? 'up' : 'down'} + value={`${(results.metrics.totalReturn || 0) >= 0 ? '+' : ''}${(results.metrics.totalReturn || 0).toFixed(2)}%`} + trend={(results.metrics.totalReturn || 0) >= 0 ? 'up' : 'down'} /> = 1 ? 'up' : 'down'} + value={results.metrics.sharpeRatio?.toFixed(2) || '0.00'} + trend={(results.metrics.sharpeRatio || 0) >= 1 ? 'up' : 'down'} /> = 50 ? 'up' : 'down'} + value={`${(results.metrics.winRate || 0).toFixed(1)}%`} + trend={(results.metrics.winRate || 0) >= 50 ? 'up' : 'down'} /> - {results.metrics.profitFactor && ( + {results.metrics.profitFactor !== null && results.metrics.profitFactor !== undefined && (