work on backtest engine
This commit is contained in:
parent
3a7557c8f4
commit
b8cefdb8cd
11 changed files with 1525 additions and 318 deletions
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
669
apps/stock/core/src/backtest/metrics_calculator.rs
Normal file
669
apps/stock/core/src/backtest/metrics_calculator.rs
Normal file
|
|
@ -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<Utc>, f64)>,
|
||||
trades: Vec<TrackedTrade>,
|
||||
fills: Vec<CompletedTrade>,
|
||||
positions: HashMap<String, Position>,
|
||||
}
|
||||
|
||||
impl MetricsCalculator {
|
||||
pub fn new(
|
||||
config: BacktestConfig,
|
||||
equity_curve: Vec<(DateTime<Utc>, f64)>,
|
||||
trades: Vec<TrackedTrade>,
|
||||
fills: Vec<CompletedTrade>,
|
||||
positions: HashMap<String, Position>,
|
||||
) -> 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<EquityPoint> = 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::<f64>();
|
||||
let total_commission = self.trades.iter().map(|t| t.commission).sum::<f64>();
|
||||
let net_pnl = total_pnl - total_commission;
|
||||
|
||||
let wins: Vec<f64> = self.trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).collect();
|
||||
let losses: Vec<f64> = self.trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl).collect();
|
||||
|
||||
let avg_win = if !wins.is_empty() {
|
||||
wins.iter().sum::<f64>() / wins.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_loss = if !losses.is_empty() {
|
||||
losses.iter().sum::<f64>() / 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::<f64>();
|
||||
let gross_loss = losses.iter().map(|l| l.abs()).sum::<f64>();
|
||||
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::<f64>() / 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::<f64>();
|
||||
|
||||
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<f64> {
|
||||
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::<f64>() / returns.len() as f64;
|
||||
let variance = returns.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>() / 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::<f64>() / 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::<f64>() / 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<f64> = returns.iter()
|
||||
.filter(|&&r| r < 0.0)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if negative_returns.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean = negative_returns.iter().sum::<f64>() / negative_returns.len() as f64;
|
||||
let variance = negative_returns.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>() / 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::<f64>() / (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<EquityPoint> {
|
||||
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<DailyReturn> {
|
||||
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<EquityPoint> {
|
||||
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<Trade> {
|
||||
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<Trade> {
|
||||
// TODO: Implement based on position tracker
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn calculate_period_returns(&self, period: &str) -> Vec<PeriodReturn> {
|
||||
// TODO: Implement monthly/yearly return calculations
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn calculate_symbol_analysis(&self) -> HashMap<String, SymbolAnalysis> {
|
||||
let mut analysis: HashMap<String, SymbolAnalysis> = 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<f64> = self.trades.iter()
|
||||
.filter(|t| t.symbol == entry.symbol && t.pnl > 0.0)
|
||||
.map(|t| t.pnl)
|
||||
.collect();
|
||||
let losses: Vec<f64> = 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::<f64>() / wins.len() as f64;
|
||||
}
|
||||
if !losses.is_empty() {
|
||||
entry.avg_loss = losses.iter().sum::<f64>() / losses.len() as f64;
|
||||
}
|
||||
|
||||
// Profit factor
|
||||
let gross_profit = wins.iter().sum::<f64>();
|
||||
let gross_loss = losses.iter().map(|l| l.abs()).sum::<f64>();
|
||||
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<DrawdownPeriod> {
|
||||
// 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<PositionSnapshot> {
|
||||
// TODO: Implement position history tracking
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extract_trade_signals(&self) -> Vec<TradeSignal> {
|
||||
// TODO: Extract signals from strategy execution
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<Utc>,
|
||||
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<f64>,
|
||||
}
|
||||
|
||||
// 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<String>,
|
||||
pub duration_days: i64,
|
||||
pub recovery_days: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<Utc>, f64)>,
|
||||
pub trades: Vec<CompletedTrade>,
|
||||
pub final_positions: HashMap<String, Position>,
|
||||
|
||||
// Time Series Data
|
||||
pub equity_curve: Vec<EquityPoint>,
|
||||
pub drawdown_curve: Vec<EquityPoint>,
|
||||
pub daily_returns: Vec<DailyReturn>,
|
||||
pub cumulative_returns: Vec<EquityPoint>,
|
||||
|
||||
// Trade Data
|
||||
pub trades: Vec<Trade>,
|
||||
pub open_trades: Vec<Trade>,
|
||||
pub trade_signals: Vec<TradeSignal>,
|
||||
|
||||
// Position Data
|
||||
pub positions: HashMap<String, Position>,
|
||||
pub position_history: Vec<PositionSnapshot>,
|
||||
|
||||
// Analytics
|
||||
pub monthly_returns: Vec<PeriodReturn>,
|
||||
pub yearly_returns: Vec<PeriodReturn>,
|
||||
pub symbol_analysis: HashMap<String, SymbolAnalysis>,
|
||||
pub drawdown_periods: Vec<DrawdownPeriod>,
|
||||
pub exposure_analysis: ExposureAnalysis,
|
||||
|
||||
// Market Data
|
||||
pub ohlc_data: HashMap<String, Vec<serde_json::Value>>,
|
||||
|
||||
// Error Handling
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warnings: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// 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<String, PositionDetail>,
|
||||
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,
|
||||
}
|
||||
551
apps/stock/core/src/backtest/simple_results.rs
Normal file
551
apps/stock/core/src/backtest/simple_results.rs
Normal file
|
|
@ -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<f64>,
|
||||
}
|
||||
|
||||
// Analytics data structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Analytics {
|
||||
pub drawdown_series: Vec<EquityPoint>,
|
||||
pub daily_returns: Vec<f64>,
|
||||
pub monthly_returns: HashMap<String, f64>,
|
||||
pub exposure_time: f64,
|
||||
pub risk_metrics: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
// 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<EquityDataPoint>,
|
||||
pub trades: Vec<CompletedTradeInfo>,
|
||||
pub positions: Vec<PositionInfo>,
|
||||
pub analytics: Analytics,
|
||||
pub execution_time: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub ohlc_data: HashMap<String, Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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<Utc>, f64)>,
|
||||
fills: Vec<CompletedTrade>,
|
||||
final_positions: HashMap<String, Position>,
|
||||
start_time: DateTime<Utc>,
|
||||
) -> 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<Trade> = 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::<f64>() / daily_returns_vec.len() as f64;
|
||||
let variance = daily_returns_vec.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>() / 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<PositionInfo> = 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<EquityDataPoint> = 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<Utc>, f64)], initial_capital: f64) -> (f64, Vec<EquityPoint>) {
|
||||
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<Utc>, f64)>,
|
||||
fills: Vec<CompletedTrade>,
|
||||
completed_trades: Vec<TrackedTrade>,
|
||||
final_positions: HashMap<String, Position>,
|
||||
start_time: DateTime<Utc>,
|
||||
last_prices: &HashMap<String, f64>,
|
||||
) -> 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<CompletedTradeInfo> = 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::<f64>() / winning_trades.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_loss = if !losing_trades.is_empty() {
|
||||
losing_trades.iter().map(|t| t.pnl).sum::<f64>() / losing_trades.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gross_profit = winning_trades.iter().map(|t| t.pnl).sum::<f64>();
|
||||
let gross_loss = losing_trades.iter().map(|t| t.pnl.abs()).sum::<f64>();
|
||||
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::<f64>() / daily_returns_vec.len() as f64;
|
||||
let variance = daily_returns_vec.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>() / 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<f64> = 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::<f64>() / negative_returns.len() as f64;
|
||||
let variance = negative_returns.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>() / 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<PositionInfo> = 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<EquityDataPoint> = 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<Trade> = 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<String, Vec<f64>> = 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,10 @@ impl TradeTracker {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn get_completed_trades(&self) -> Vec<CompletedTrade> {
|
||||
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<String, Vec<(Side, f64, f64)>> {
|
||||
let mut result = HashMap::new();
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ export class RustBacktestAdapter extends EventEmitter {
|
|||
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<string, any[]> = {};
|
||||
for (const symbol of config.symbols) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ===');
|
||||
|
|
|
|||
|
|
@ -81,29 +81,29 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult
|
|||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<MetricsCard
|
||||
title="Total Return"
|
||||
value={`${results.metrics.totalReturn >= 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'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Sharpe Ratio"
|
||||
value={results.metrics.sharpeRatio.toFixed(2)}
|
||||
trend={results.metrics.sharpeRatio >= 1 ? 'up' : 'down'}
|
||||
value={results.metrics.sharpeRatio?.toFixed(2) || '0.00'}
|
||||
trend={(results.metrics.sharpeRatio || 0) >= 1 ? 'up' : 'down'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Max Drawdown"
|
||||
value={`${(results.metrics.maxDrawdown * 100).toFixed(2)}%`}
|
||||
value={`${((results.metrics.maxDrawdown || 0) * 100).toFixed(2)}%`}
|
||||
trend="down"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Win Rate"
|
||||
value={`${results.metrics.winRate.toFixed(1)}%`}
|
||||
trend={results.metrics.winRate >= 50 ? 'up' : 'down'}
|
||||
value={`${(results.metrics.winRate || 0).toFixed(1)}%`}
|
||||
trend={(results.metrics.winRate || 0) >= 50 ? 'up' : 'down'}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Total Trades"
|
||||
value={results.metrics.totalTrades.toString()}
|
||||
value={(results.metrics.totalTrades || 0).toString()}
|
||||
/>
|
||||
{results.metrics.profitFactor && (
|
||||
{results.metrics.profitFactor !== null && results.metrics.profitFactor !== undefined && (
|
||||
<MetricsCard
|
||||
title="Profit Factor"
|
||||
value={results.metrics.profitFactor.toFixed(2)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue