work on backtest engine

This commit is contained in:
Boki 2025-07-04 07:45:56 -04:00
parent 3a7557c8f4
commit b8cefdb8cd
11 changed files with 1525 additions and 318 deletions

Binary file not shown.

View file

@ -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)

View file

@ -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,
)
}
}

View 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()
}
}

View file

@ -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)]

View file

@ -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,
}

View 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(),
}
}
}

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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 ===');

View file

@ -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)}