- Fixed 101+ files importing common::types::prelude which doesn't exist - Changed all imports to use common::types directly - Fixed BarEvent duplicate import in data/src/types.rs - Aligned all imports with canonical type system in common crate
1259 lines
38 KiB
Rust
1259 lines
38 KiB
Rust
//! Performance analytics and metrics for backtesting
|
|
//!
|
|
//! Provides comprehensive performance analysis including returns, risk metrics,
|
|
//! drawdown analysis, and statistical measures for strategy evaluation.
|
|
|
|
use std::{
|
|
collections::{HashMap, VecDeque},
|
|
sync::Arc,
|
|
};
|
|
|
|
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use statrs::statistics::{Statistics, VarianceN};
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use common::types::*;
|
|
|
|
use crate::strategy_tester::{PerformanceSnapshot, TradeRecord};
|
|
|
|
/// Comprehensive performance analytics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PerformanceAnalytics {
|
|
/// Basic return metrics
|
|
pub returns: ReturnMetrics,
|
|
/// Risk metrics
|
|
pub risk: RiskMetrics,
|
|
/// Drawdown analysis
|
|
pub drawdown: DrawdownMetrics,
|
|
/// Trade statistics
|
|
pub trade_stats: TradeStatistics,
|
|
/// Benchmark comparison
|
|
pub benchmark: Option<BenchmarkComparison>,
|
|
/// Portfolio metrics
|
|
pub portfolio: PortfolioMetrics,
|
|
/// Time-based analysis
|
|
pub time_analysis: TimeAnalysis,
|
|
}
|
|
|
|
/// Return-based metrics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ReturnMetrics {
|
|
/// Total return
|
|
pub total_return: Decimal,
|
|
/// Annualized return
|
|
pub annualized_return: Decimal,
|
|
/// Compound annual growth rate (CAGR)
|
|
pub cagr: Decimal,
|
|
/// Daily returns
|
|
pub daily_returns: Vec<Decimal>,
|
|
/// Monthly returns
|
|
pub monthly_returns: Vec<Decimal>,
|
|
/// Best single day return
|
|
pub best_day: Decimal,
|
|
/// Worst single day return
|
|
pub worst_day: Decimal,
|
|
/// Average daily return
|
|
pub avg_daily_return: Decimal,
|
|
/// Median daily return
|
|
pub median_daily_return: Decimal,
|
|
/// Return standard deviation
|
|
pub return_std: Decimal,
|
|
/// Skewness of returns
|
|
pub skewness: Decimal,
|
|
/// Kurtosis of returns
|
|
pub kurtosis: Decimal,
|
|
}
|
|
|
|
/// Risk-based metrics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RiskMetrics {
|
|
/// Sharpe ratio
|
|
pub sharpe_ratio: Decimal,
|
|
/// Sortino ratio
|
|
pub sortino_ratio: Decimal,
|
|
/// Calmar ratio
|
|
pub calmar_ratio: Decimal,
|
|
/// Value at Risk (VaR) 95%
|
|
pub var_95: Decimal,
|
|
/// Value at Risk (VaR) 99%
|
|
pub var_99: Decimal,
|
|
/// Conditional Value at Risk (CVaR) 95%
|
|
pub cvar_95: Decimal,
|
|
/// Maximum consecutive losses
|
|
pub max_consecutive_losses: u32,
|
|
/// Beta (if benchmark provided)
|
|
pub beta: Option<Decimal>,
|
|
/// Alpha (if benchmark provided)
|
|
pub alpha: Option<Decimal>,
|
|
/// Tracking error (if benchmark provided)
|
|
pub tracking_error: Option<Decimal>,
|
|
/// Information ratio (if benchmark provided)
|
|
pub information_ratio: Option<Decimal>,
|
|
}
|
|
|
|
/// Drawdown analysis
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DrawdownMetrics {
|
|
/// Maximum drawdown
|
|
pub max_drawdown: Decimal,
|
|
/// Current drawdown
|
|
pub current_drawdown: Decimal,
|
|
/// Average drawdown
|
|
pub avg_drawdown: Decimal,
|
|
/// Maximum drawdown duration (days)
|
|
pub max_drawdown_duration: i64,
|
|
/// Current drawdown duration (days)
|
|
pub current_drawdown_duration: i64,
|
|
/// Recovery time from max drawdown (days)
|
|
pub recovery_time: Option<i64>,
|
|
/// Drawdown periods
|
|
pub drawdown_periods: Vec<DrawdownPeriod>,
|
|
/// Underwater curve
|
|
pub underwater_curve: Vec<(DateTime<Utc>, Decimal)>,
|
|
}
|
|
|
|
/// Individual drawdown period
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DrawdownPeriod {
|
|
/// Start date of drawdown
|
|
pub start_date: DateTime<Utc>,
|
|
/// End date of drawdown
|
|
pub end_date: Option<DateTime<Utc>>,
|
|
/// Peak value before drawdown
|
|
pub peak_value: Decimal,
|
|
/// Trough value during drawdown
|
|
pub trough_value: Decimal,
|
|
/// Maximum drawdown during period
|
|
pub max_drawdown: Decimal,
|
|
/// Duration in days
|
|
pub duration: i64,
|
|
/// Recovery date
|
|
pub recovery_date: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// Trade-based statistics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TradeStatistics {
|
|
/// Total number of trades
|
|
pub total_trades: u64,
|
|
/// Winning trades
|
|
pub winning_trades: u64,
|
|
/// Losing trades
|
|
pub losing_trades: u64,
|
|
/// Win rate
|
|
pub win_rate: Decimal,
|
|
/// Average trade return
|
|
pub avg_trade_return: Decimal,
|
|
/// Average winning trade
|
|
pub avg_winning_trade: Decimal,
|
|
/// Average losing trade
|
|
pub avg_losing_trade: Decimal,
|
|
/// Best trade return
|
|
pub best_trade: Decimal,
|
|
/// Worst trade return
|
|
pub worst_trade: Decimal,
|
|
/// Profit factor
|
|
pub profit_factor: Decimal,
|
|
/// Average trade duration
|
|
pub avg_trade_duration: ChronoDuration,
|
|
/// Trades per symbol
|
|
pub trades_per_symbol: HashMap<Symbol, u64>,
|
|
/// Monthly trade count
|
|
pub monthly_trade_count: Vec<(DateTime<Utc>, u64)>,
|
|
}
|
|
|
|
/// Portfolio-level metrics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PortfolioMetrics {
|
|
/// Initial capital
|
|
pub initial_capital: Decimal,
|
|
/// Final portfolio value
|
|
pub final_value: Decimal,
|
|
/// Peak portfolio value
|
|
pub peak_value: Decimal,
|
|
/// Average portfolio value
|
|
pub avg_portfolio_value: Decimal,
|
|
/// Total fees paid
|
|
pub total_fees: Decimal,
|
|
/// Total slippage cost
|
|
pub total_slippage: Decimal,
|
|
/// Portfolio turnover
|
|
pub turnover: Decimal,
|
|
/// Average number of positions
|
|
pub avg_positions: Decimal,
|
|
/// Maximum positions held
|
|
pub max_positions: u32,
|
|
/// Cash utilization
|
|
pub cash_utilization: Decimal,
|
|
}
|
|
|
|
/// Time-based analysis
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TimeAnalysis {
|
|
/// Strategy start date
|
|
pub start_date: DateTime<Utc>,
|
|
/// Strategy end date
|
|
pub end_date: DateTime<Utc>,
|
|
/// Total days
|
|
pub total_days: i64,
|
|
/// Trading days
|
|
pub trading_days: i64,
|
|
/// Monthly performance
|
|
pub monthly_performance: Vec<MonthlyPerformance>,
|
|
/// Yearly performance
|
|
pub yearly_performance: Vec<YearlyPerformance>,
|
|
/// Best month
|
|
pub best_month: Decimal,
|
|
/// Worst month
|
|
pub worst_month: Decimal,
|
|
/// Best year
|
|
pub best_year: Decimal,
|
|
/// Worst year
|
|
pub worst_year: Decimal,
|
|
}
|
|
|
|
/// Monthly performance summary
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MonthlyPerformance {
|
|
/// Month/year
|
|
pub month: DateTime<Utc>,
|
|
/// Return for the month
|
|
pub return_pct: Decimal,
|
|
/// Number of trades
|
|
pub trade_count: u64,
|
|
/// Win rate for the month
|
|
pub win_rate: Decimal,
|
|
/// Portfolio value at month end
|
|
pub portfolio_value: Decimal,
|
|
}
|
|
|
|
/// Yearly performance summary
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct YearlyPerformance {
|
|
/// Year
|
|
pub year: i32,
|
|
/// Return for the year
|
|
pub return_pct: Decimal,
|
|
/// Number of trades
|
|
pub trade_count: u64,
|
|
/// Win rate for the year
|
|
pub win_rate: Decimal,
|
|
/// Portfolio value at year end
|
|
pub portfolio_value: Decimal,
|
|
/// Maximum drawdown during year
|
|
pub max_drawdown: Decimal,
|
|
}
|
|
|
|
/// Benchmark comparison metrics
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BenchmarkComparison {
|
|
/// Benchmark name
|
|
pub benchmark_name: String,
|
|
/// Benchmark total return
|
|
pub benchmark_return: Decimal,
|
|
/// Strategy excess return
|
|
pub excess_return: Decimal,
|
|
/// Beta coefficient
|
|
pub beta: Decimal,
|
|
/// Alpha (risk-adjusted excess return)
|
|
pub alpha: Decimal,
|
|
/// Tracking error
|
|
pub tracking_error: Decimal,
|
|
/// Information ratio
|
|
pub information_ratio: Decimal,
|
|
/// Up capture ratio
|
|
pub up_capture: Decimal,
|
|
/// Down capture ratio
|
|
pub down_capture: Decimal,
|
|
}
|
|
|
|
/// Performance metrics calculator
|
|
pub struct MetricsCalculator {
|
|
/// Performance snapshots
|
|
snapshots: Vec<PerformanceSnapshot>,
|
|
/// Trade records
|
|
trades: Vec<TradeRecord>,
|
|
/// Benchmark data (if available)
|
|
benchmark_data: Option<Vec<(DateTime<Utc>, Decimal)>>,
|
|
/// Risk-free rate (annualized)
|
|
risk_free_rate: Decimal,
|
|
}
|
|
|
|
impl MetricsCalculator {
|
|
/// Create new metrics calculator
|
|
pub fn new(risk_free_rate: Decimal) -> Self {
|
|
Self {
|
|
snapshots: Vec::new(),
|
|
trades: Vec::new(),
|
|
benchmark_data: None,
|
|
risk_free_rate,
|
|
}
|
|
}
|
|
|
|
/// Add performance snapshot
|
|
pub fn add_snapshot(&mut self, snapshot: PerformanceSnapshot) {
|
|
self.snapshots.push(snapshot);
|
|
}
|
|
|
|
/// Add trade record
|
|
pub fn add_trade(&mut self, trade: TradeRecord) {
|
|
self.trades.push(trade);
|
|
}
|
|
|
|
/// Set benchmark data
|
|
pub fn set_benchmark(&mut self, benchmark_name: String, data: Vec<(DateTime<Utc>, Decimal)>) {
|
|
self.benchmark_data = Some(data);
|
|
}
|
|
|
|
/// Calculate comprehensive performance analytics
|
|
pub fn calculate_analytics(&self) -> Result<PerformanceAnalytics> {
|
|
if self.snapshots.is_empty() {
|
|
return Err(anyhow::anyhow!("No performance snapshots available"));
|
|
}
|
|
|
|
info!(
|
|
"Calculating performance analytics for {} snapshots and {} trades",
|
|
self.snapshots.len(),
|
|
self.trades.len()
|
|
);
|
|
|
|
let returns = self.calculate_return_metrics()?;
|
|
let risk = self.calculate_risk_metrics(&returns)?;
|
|
let drawdown = self.calculate_drawdown_metrics()?;
|
|
let trade_stats = self.calculate_trade_statistics()?;
|
|
let benchmark = self.calculate_benchmark_comparison(&returns)?;
|
|
let portfolio = self.calculate_portfolio_metrics()?;
|
|
let time_analysis = self.calculate_time_analysis()?;
|
|
|
|
Ok(PerformanceAnalytics {
|
|
returns,
|
|
risk,
|
|
drawdown,
|
|
trade_stats,
|
|
benchmark,
|
|
portfolio,
|
|
time_analysis,
|
|
})
|
|
}
|
|
|
|
/// Calculate return-based metrics
|
|
fn calculate_return_metrics(&self) -> Result<ReturnMetrics> {
|
|
let daily_returns = self.calculate_daily_returns()?;
|
|
|
|
if daily_returns.is_empty() {
|
|
return Err(anyhow::anyhow!("No daily returns calculated"));
|
|
}
|
|
|
|
let total_return = self.calculate_total_return()?;
|
|
let annualized_return = self.calculate_annualized_return(&daily_returns)?;
|
|
let cagr = self.calculate_cagr()?;
|
|
|
|
let returns_f64: Vec<f64> = daily_returns
|
|
.iter()
|
|
.map(|d| d.to_string().parse().unwrap_or(0.0))
|
|
.collect();
|
|
|
|
let avg_daily_return = if !returns_f64.is_empty() {
|
|
Decimal::from_f64_retain(returns_f64.clone().mean()).unwrap_or_default()
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let return_std = if returns_f64.len() > 1 {
|
|
Decimal::from_f64_retain(returns_f64.clone().std_dev()).unwrap_or_default()
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let best_day = daily_returns.iter().max().cloned().unwrap_or_default();
|
|
let worst_day = daily_returns.iter().min().cloned().unwrap_or_default();
|
|
|
|
// Calculate median
|
|
let mut sorted_returns = daily_returns.clone();
|
|
sorted_returns.sort();
|
|
let median_daily_return = if !sorted_returns.is_empty() {
|
|
if sorted_returns.len() % 2 == 0 {
|
|
let mid = sorted_returns.len() / 2;
|
|
(sorted_returns[mid - 1] + sorted_returns[mid]) / Decimal::from(2)
|
|
} else {
|
|
sorted_returns[sorted_returns.len() / 2]
|
|
}
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
// Calculate skewness and kurtosis (simplified)
|
|
let skewness = self.calculate_skewness(&returns_f64);
|
|
let kurtosis = self.calculate_kurtosis(&returns_f64);
|
|
|
|
let monthly_returns = self.calculate_monthly_returns()?;
|
|
|
|
Ok(ReturnMetrics {
|
|
total_return,
|
|
annualized_return,
|
|
cagr,
|
|
daily_returns,
|
|
monthly_returns,
|
|
best_day,
|
|
worst_day,
|
|
avg_daily_return,
|
|
median_daily_return,
|
|
return_std,
|
|
skewness,
|
|
kurtosis,
|
|
})
|
|
}
|
|
|
|
/// Calculate risk metrics
|
|
fn calculate_risk_metrics(&self, returns: &ReturnMetrics) -> Result<RiskMetrics> {
|
|
let sharpe_ratio = self.calculate_sharpe_ratio(&returns.daily_returns)?;
|
|
let sortino_ratio = self.calculate_sortino_ratio(&returns.daily_returns)?;
|
|
let calmar_ratio = self.calculate_calmar_ratio(returns.annualized_return)?;
|
|
|
|
let (var_95, var_99) = self.calculate_var(&returns.daily_returns)?;
|
|
let cvar_95 = self.calculate_cvar(&returns.daily_returns, Decimal::new(5, 2))?;
|
|
|
|
let max_consecutive_losses = self.calculate_max_consecutive_losses()?;
|
|
|
|
// Benchmark-related metrics
|
|
let (beta, alpha, tracking_error, information_ratio) = if self.benchmark_data.is_some() {
|
|
self.calculate_benchmark_risk_metrics(&returns.daily_returns)?
|
|
} else {
|
|
(None, None, None, None)
|
|
};
|
|
|
|
Ok(RiskMetrics {
|
|
sharpe_ratio,
|
|
sortino_ratio,
|
|
calmar_ratio,
|
|
var_95,
|
|
var_99,
|
|
cvar_95,
|
|
max_consecutive_losses,
|
|
beta,
|
|
alpha,
|
|
tracking_error,
|
|
information_ratio,
|
|
})
|
|
}
|
|
|
|
/// Calculate drawdown metrics
|
|
fn calculate_drawdown_metrics(&self) -> Result<DrawdownMetrics> {
|
|
let (max_drawdown, current_drawdown, drawdown_periods, underwater_curve) =
|
|
self.calculate_drawdowns()?;
|
|
|
|
let avg_drawdown = if !drawdown_periods.is_empty() {
|
|
let sum: Decimal = drawdown_periods.iter().map(|p| p.max_drawdown).sum();
|
|
sum / Decimal::from(drawdown_periods.len())
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let max_drawdown_duration = drawdown_periods
|
|
.iter()
|
|
.map(|p| p.duration)
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
let current_drawdown_duration = if let Some(period) = drawdown_periods.last() {
|
|
if period.end_date.is_none() {
|
|
period.duration
|
|
} else {
|
|
0
|
|
}
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let recovery_time = drawdown_periods
|
|
.iter()
|
|
.find(|p| p.max_drawdown == max_drawdown)
|
|
.and_then(|p| p.recovery_date)
|
|
.map(|recovery| {
|
|
if let Some(peak_period) = drawdown_periods
|
|
.iter()
|
|
.find(|pp| pp.max_drawdown == max_drawdown)
|
|
{
|
|
(recovery - peak_period.start_date).num_days()
|
|
} else {
|
|
0
|
|
}
|
|
});
|
|
|
|
Ok(DrawdownMetrics {
|
|
max_drawdown,
|
|
current_drawdown,
|
|
avg_drawdown,
|
|
max_drawdown_duration,
|
|
current_drawdown_duration,
|
|
recovery_time,
|
|
drawdown_periods,
|
|
underwater_curve,
|
|
})
|
|
}
|
|
|
|
/// Calculate trade statistics
|
|
fn calculate_trade_statistics(&self) -> Result<TradeStatistics> {
|
|
if self.trades.is_empty() {
|
|
return Ok(TradeStatistics {
|
|
total_trades: 0,
|
|
winning_trades: 0,
|
|
losing_trades: 0,
|
|
win_rate: Decimal::ZERO,
|
|
avg_trade_return: Decimal::ZERO,
|
|
avg_winning_trade: Decimal::ZERO,
|
|
avg_losing_trade: Decimal::ZERO,
|
|
best_trade: Decimal::ZERO,
|
|
worst_trade: Decimal::ZERO,
|
|
profit_factor: Decimal::ZERO,
|
|
avg_trade_duration: ChronoDuration::zero(),
|
|
trades_per_symbol: HashMap::new(),
|
|
monthly_trade_count: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let total_trades = self.trades.len() as u64;
|
|
let winning_trades = self
|
|
.trades
|
|
.iter()
|
|
.filter(|t| t.return_pct > Decimal::ZERO)
|
|
.count() as u64;
|
|
let losing_trades = total_trades - winning_trades;
|
|
|
|
let win_rate = if total_trades > 0 {
|
|
Decimal::from(winning_trades) / Decimal::from(total_trades)
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let avg_trade_return = if !self.trades.is_empty() {
|
|
let sum: Decimal = self.trades.iter().map(|t| t.return_pct).sum();
|
|
sum / Decimal::from(self.trades.len())
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let winning_trades_vec: Vec<_> = self
|
|
.trades
|
|
.iter()
|
|
.filter(|t| t.return_pct > Decimal::ZERO)
|
|
.collect();
|
|
|
|
let losing_trades_vec: Vec<_> = self
|
|
.trades
|
|
.iter()
|
|
.filter(|t| t.return_pct <= Decimal::ZERO)
|
|
.collect();
|
|
|
|
let avg_winning_trade = if !winning_trades_vec.is_empty() {
|
|
let sum: Decimal = winning_trades_vec.iter().map(|t| t.return_pct).sum();
|
|
sum / Decimal::from(winning_trades_vec.len())
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let avg_losing_trade = if !losing_trades_vec.is_empty() {
|
|
let sum: Decimal = losing_trades_vec.iter().map(|t| t.return_pct).sum();
|
|
sum / Decimal::from(losing_trades_vec.len())
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let best_trade = self
|
|
.trades
|
|
.iter()
|
|
.map(|t| t.return_pct)
|
|
.max()
|
|
.unwrap_or_default();
|
|
|
|
let worst_trade = self
|
|
.trades
|
|
.iter()
|
|
.map(|t| t.return_pct)
|
|
.min()
|
|
.unwrap_or_default();
|
|
|
|
let gross_profit: Decimal = winning_trades_vec.iter().map(|t| t.pnl).sum();
|
|
let gross_loss: Decimal = losing_trades_vec.iter().map(|t| t.pnl.abs()).sum();
|
|
|
|
let profit_factor = if gross_loss > Decimal::ZERO {
|
|
gross_profit / gross_loss
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let avg_trade_duration = if !self.trades.is_empty() {
|
|
let total_duration: i64 = self
|
|
.trades
|
|
.iter()
|
|
.map(|t| (t.exit_time - t.entry_time).num_seconds())
|
|
.sum();
|
|
ChronoDuration::seconds(total_duration / self.trades.len() as i64)
|
|
} else {
|
|
ChronoDuration::zero()
|
|
};
|
|
|
|
// Calculate trades per symbol
|
|
let mut trades_per_symbol = HashMap::new();
|
|
for trade in &self.trades {
|
|
*trades_per_symbol.entry(trade.symbol.clone()).or_insert(0) += 1;
|
|
}
|
|
|
|
// Calculate monthly trade count
|
|
let monthly_trade_count = self.calculate_monthly_trade_count();
|
|
|
|
Ok(TradeStatistics {
|
|
total_trades,
|
|
winning_trades,
|
|
losing_trades,
|
|
win_rate,
|
|
avg_trade_return,
|
|
avg_winning_trade,
|
|
avg_losing_trade,
|
|
best_trade,
|
|
worst_trade,
|
|
profit_factor,
|
|
avg_trade_duration,
|
|
trades_per_symbol,
|
|
monthly_trade_count,
|
|
})
|
|
}
|
|
|
|
/// Calculate benchmark comparison if available
|
|
fn calculate_benchmark_comparison(
|
|
&self,
|
|
returns: &ReturnMetrics,
|
|
) -> Result<Option<BenchmarkComparison>> {
|
|
if let Some(_benchmark_data) = &self.benchmark_data {
|
|
// Benchmark comparison implementation would go here
|
|
// Implementation for comprehensive benchmark analysis
|
|
warn!("Benchmark comparison not yet fully implemented");
|
|
Ok(None)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Calculate portfolio metrics
|
|
fn calculate_portfolio_metrics(&self) -> Result<PortfolioMetrics> {
|
|
if self.snapshots.is_empty() {
|
|
return Err(anyhow::anyhow!(
|
|
"No snapshots available for portfolio metrics"
|
|
));
|
|
}
|
|
|
|
let initial_capital = self.snapshots[0].portfolio_value;
|
|
let final_value = self
|
|
.snapshots
|
|
.last()
|
|
.ok_or_else(|| anyhow::anyhow!("No snapshots available for final value calculation"))?
|
|
.portfolio_value;
|
|
let peak_value = self
|
|
.snapshots
|
|
.iter()
|
|
.map(|s| s.portfolio_value)
|
|
.max()
|
|
.unwrap_or(initial_capital);
|
|
|
|
let avg_portfolio_value = if !self.snapshots.is_empty() {
|
|
let sum: Decimal = self.snapshots.iter().map(|s| s.portfolio_value).sum();
|
|
sum / Decimal::from(self.snapshots.len())
|
|
} else {
|
|
initial_capital
|
|
};
|
|
|
|
let total_fees = self.trades.iter().map(|t| t.commission).sum();
|
|
let total_slippage = Decimal::ZERO; // Would be calculated from execution data
|
|
|
|
// Portfolio turnover calculation (simplified)
|
|
let turnover = if !self.trades.is_empty() && avg_portfolio_value > Decimal::ZERO {
|
|
let total_traded: Decimal = self
|
|
.trades
|
|
.iter()
|
|
.map(|t| {
|
|
t.quantity.to_decimal().unwrap_or_default()
|
|
* t.entry_price.to_decimal().unwrap_or_default()
|
|
})
|
|
.sum();
|
|
total_traded / avg_portfolio_value
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let avg_positions = if !self.snapshots.is_empty() {
|
|
let sum = self.snapshots.iter().map(|s| s.open_positions).sum::<u32>();
|
|
Decimal::from(sum) / Decimal::from(self.snapshots.len())
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let max_positions = self
|
|
.snapshots
|
|
.iter()
|
|
.map(|s| s.open_positions)
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
let cash_utilization = if initial_capital > Decimal::ZERO {
|
|
let final_cash = self
|
|
.snapshots
|
|
.last()
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("No snapshots available for cash utilization calculation")
|
|
})?
|
|
.cash_balance;
|
|
(initial_capital - final_cash) / initial_capital
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
Ok(PortfolioMetrics {
|
|
initial_capital,
|
|
final_value,
|
|
peak_value,
|
|
avg_portfolio_value,
|
|
total_fees,
|
|
total_slippage,
|
|
turnover,
|
|
avg_positions,
|
|
max_positions,
|
|
cash_utilization,
|
|
})
|
|
}
|
|
|
|
/// Calculate time-based analysis
|
|
fn calculate_time_analysis(&self) -> Result<TimeAnalysis> {
|
|
if self.snapshots.is_empty() {
|
|
return Err(anyhow::anyhow!("No snapshots available for time analysis"));
|
|
}
|
|
|
|
let start_date = self.snapshots[0].timestamp;
|
|
let end_date = self
|
|
.snapshots
|
|
.last()
|
|
.ok_or_else(|| anyhow::anyhow!("No snapshots available for time analysis end date"))?
|
|
.timestamp;
|
|
let total_days = (end_date - start_date).num_days();
|
|
let trading_days = self.snapshots.len() as i64; // Simplified
|
|
|
|
let monthly_performance = self.calculate_monthly_performance()?;
|
|
let yearly_performance = self.calculate_yearly_performance()?;
|
|
|
|
let best_month = monthly_performance
|
|
.iter()
|
|
.map(|m| m.return_pct)
|
|
.max()
|
|
.unwrap_or_default();
|
|
|
|
let worst_month = monthly_performance
|
|
.iter()
|
|
.map(|m| m.return_pct)
|
|
.min()
|
|
.unwrap_or_default();
|
|
|
|
let best_year = yearly_performance
|
|
.iter()
|
|
.map(|y| y.return_pct)
|
|
.max()
|
|
.unwrap_or_default();
|
|
|
|
let worst_year = yearly_performance
|
|
.iter()
|
|
.map(|y| y.return_pct)
|
|
.min()
|
|
.unwrap_or_default();
|
|
|
|
Ok(TimeAnalysis {
|
|
start_date,
|
|
end_date,
|
|
total_days,
|
|
trading_days,
|
|
monthly_performance,
|
|
yearly_performance,
|
|
best_month,
|
|
worst_month,
|
|
best_year,
|
|
worst_year,
|
|
})
|
|
}
|
|
|
|
// Helper methods for calculations
|
|
|
|
fn calculate_daily_returns(&self) -> Result<Vec<Decimal>> {
|
|
if self.snapshots.len() < 2 {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut returns = Vec::new();
|
|
for i in 1..self.snapshots.len() {
|
|
let prev_value = self.snapshots[i - 1].portfolio_value;
|
|
let curr_value = self.snapshots[i].portfolio_value;
|
|
|
|
if prev_value > Decimal::ZERO {
|
|
let return_pct = (curr_value - prev_value) / prev_value;
|
|
returns.push(return_pct);
|
|
}
|
|
}
|
|
|
|
Ok(returns)
|
|
}
|
|
|
|
fn calculate_total_return(&self) -> Result<Decimal> {
|
|
if self.snapshots.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let initial_value = self.snapshots[0].portfolio_value;
|
|
let final_value = self
|
|
.snapshots
|
|
.last()
|
|
.ok_or_else(|| anyhow::anyhow!("No snapshots available for total return calculation"))?
|
|
.portfolio_value;
|
|
|
|
if initial_value > Decimal::ZERO {
|
|
Ok((final_value - initial_value) / initial_value)
|
|
} else {
|
|
Ok(Decimal::ZERO)
|
|
}
|
|
}
|
|
|
|
fn calculate_annualized_return(&self, daily_returns: &[Decimal]) -> Result<Decimal> {
|
|
if daily_returns.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
// Compound daily returns to get annualized return
|
|
let compound_return = daily_returns
|
|
.iter()
|
|
.fold(Decimal::from(1), |acc, &ret| acc * (Decimal::from(1) + ret));
|
|
|
|
let days = daily_returns.len() as f64;
|
|
let years = days / 365.25;
|
|
|
|
if years > 0.0 && compound_return > Decimal::ZERO {
|
|
let annualized = compound_return.powf(1.0 / years) - Decimal::from(1);
|
|
Ok(annualized)
|
|
} else {
|
|
Ok(Decimal::ZERO)
|
|
}
|
|
}
|
|
|
|
fn calculate_cagr(&self) -> Result<Decimal> {
|
|
if self.snapshots.len() < 2 {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let initial_value = self.snapshots[0].portfolio_value;
|
|
let final_value = self
|
|
.snapshots
|
|
.last()
|
|
.ok_or_else(|| anyhow::anyhow!("No snapshots available for CAGR calculation"))?
|
|
.portfolio_value;
|
|
let start_date = self.snapshots[0].timestamp;
|
|
let end_date = self
|
|
.snapshots
|
|
.last()
|
|
.ok_or_else(|| anyhow::anyhow!("No snapshots available for CAGR end date"))?
|
|
.timestamp;
|
|
|
|
let years = (end_date - start_date).num_days() as f64 / 365.25;
|
|
|
|
if years > 0.0 && initial_value > Decimal::ZERO && final_value > Decimal::ZERO {
|
|
let cagr = (final_value / initial_value).powf(1.0 / years) - Decimal::from(1);
|
|
Ok(cagr)
|
|
} else {
|
|
Ok(Decimal::ZERO)
|
|
}
|
|
}
|
|
|
|
fn calculate_sharpe_ratio(&self, daily_returns: &[Decimal]) -> Result<Decimal> {
|
|
if daily_returns.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let returns_f64: Vec<f64> = daily_returns
|
|
.iter()
|
|
.map(|d| d.to_string().parse().unwrap_or(0.0))
|
|
.collect();
|
|
|
|
let mean_return = returns_f64.clone().mean();
|
|
let std_dev = if returns_f64.len() > 1 {
|
|
returns_f64.clone().std_dev()
|
|
} else {
|
|
return Ok(Decimal::ZERO);
|
|
};
|
|
|
|
let daily_risk_free = (self.risk_free_rate / Decimal::from(365))
|
|
.to_string()
|
|
.parse::<f64>()
|
|
.unwrap_or(0.0);
|
|
let excess_return = mean_return - daily_risk_free;
|
|
|
|
if std_dev > 0.0 {
|
|
let sharpe = excess_return / std_dev;
|
|
let annualized_sharpe = sharpe * (365.25_f64).sqrt();
|
|
Ok(Decimal::from_f64_retain(annualized_sharpe).unwrap_or_default())
|
|
} else {
|
|
Ok(Decimal::ZERO)
|
|
}
|
|
}
|
|
|
|
fn calculate_sortino_ratio(&self, daily_returns: &[Decimal]) -> Result<Decimal> {
|
|
if daily_returns.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let returns_f64: Vec<f64> = daily_returns
|
|
.iter()
|
|
.map(|d| d.to_string().parse().unwrap_or(0.0))
|
|
.collect();
|
|
|
|
let mean_return = returns_f64.clone().mean();
|
|
let daily_risk_free = (self.risk_free_rate / Decimal::from(365))
|
|
.to_string()
|
|
.parse::<f64>()
|
|
.unwrap_or(0.0);
|
|
|
|
// Calculate downside deviation
|
|
let negative_returns: Vec<f64> = returns_f64
|
|
.iter()
|
|
.filter(|&&r| r < daily_risk_free)
|
|
.map(|&r| (r - daily_risk_free).powi(2))
|
|
.collect();
|
|
|
|
if negative_returns.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let downside_deviation =
|
|
(negative_returns.iter().sum::<f64>() / negative_returns.len() as f64).sqrt();
|
|
|
|
if downside_deviation > 0.0 {
|
|
let sortino = (mean_return - daily_risk_free) / downside_deviation;
|
|
let annualized_sortino = sortino * (365.25_f64).sqrt();
|
|
Ok(Decimal::from_f64_retain(annualized_sortino).unwrap_or_default())
|
|
} else {
|
|
Ok(Decimal::ZERO)
|
|
}
|
|
}
|
|
|
|
fn calculate_calmar_ratio(&self, annualized_return: Decimal) -> Result<Decimal> {
|
|
let max_drawdown = self.calculate_max_drawdown()?;
|
|
|
|
if max_drawdown.abs() > Decimal::ZERO {
|
|
Ok(annualized_return / max_drawdown.abs())
|
|
} else {
|
|
Ok(Decimal::ZERO)
|
|
}
|
|
}
|
|
|
|
fn calculate_max_drawdown(&self) -> Result<Decimal> {
|
|
if self.snapshots.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let mut max_dd = Decimal::ZERO;
|
|
let mut peak = self.snapshots[0].portfolio_value;
|
|
|
|
for snapshot in &self.snapshots {
|
|
if snapshot.portfolio_value > peak {
|
|
peak = snapshot.portfolio_value;
|
|
}
|
|
|
|
let drawdown = (snapshot.portfolio_value - peak) / peak;
|
|
if drawdown < max_dd {
|
|
max_dd = drawdown;
|
|
}
|
|
}
|
|
|
|
Ok(max_dd)
|
|
}
|
|
|
|
fn calculate_var(&self, daily_returns: &[Decimal]) -> Result<(Decimal, Decimal)> {
|
|
if daily_returns.is_empty() {
|
|
return Ok((Decimal::ZERO, Decimal::ZERO));
|
|
}
|
|
|
|
let mut sorted_returns = daily_returns.to_vec();
|
|
sorted_returns.sort();
|
|
|
|
let var_95_idx = (sorted_returns.len() as f64 * 0.05) as usize;
|
|
let var_99_idx = (sorted_returns.len() as f64 * 0.01) as usize;
|
|
|
|
let var_95 = if var_95_idx < sorted_returns.len() {
|
|
sorted_returns[var_95_idx]
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
let var_99 = if var_99_idx < sorted_returns.len() {
|
|
sorted_returns[var_99_idx]
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
Ok((var_95, var_99))
|
|
}
|
|
|
|
fn calculate_cvar(
|
|
&self,
|
|
daily_returns: &[Decimal],
|
|
confidence_level: Decimal,
|
|
) -> Result<Decimal> {
|
|
if daily_returns.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let mut sorted_returns = daily_returns.to_vec();
|
|
sorted_returns.sort();
|
|
|
|
let cutoff_idx = (sorted_returns.len() as f64
|
|
* confidence_level.to_string().parse::<f64>().unwrap_or(0.05))
|
|
as usize;
|
|
|
|
if cutoff_idx == 0 {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let tail_returns = &sorted_returns[..cutoff_idx];
|
|
if tail_returns.is_empty() {
|
|
return Ok(Decimal::ZERO);
|
|
}
|
|
|
|
let cvar = tail_returns.iter().sum::<Decimal>() / Decimal::from(tail_returns.len());
|
|
Ok(cvar)
|
|
}
|
|
|
|
fn calculate_max_consecutive_losses(&self) -> Result<u32> {
|
|
let mut max_consecutive = 0;
|
|
let mut current_consecutive = 0;
|
|
|
|
for trade in &self.trades {
|
|
if trade.return_pct < Decimal::ZERO {
|
|
current_consecutive += 1;
|
|
max_consecutive = max_consecutive.max(current_consecutive);
|
|
} else {
|
|
current_consecutive = 0;
|
|
}
|
|
}
|
|
|
|
Ok(max_consecutive)
|
|
}
|
|
|
|
fn calculate_benchmark_risk_metrics(
|
|
&self,
|
|
_daily_returns: &[Decimal],
|
|
) -> Result<(
|
|
Option<Decimal>,
|
|
Option<Decimal>,
|
|
Option<Decimal>,
|
|
Option<Decimal>,
|
|
)> {
|
|
// Implementation for benchmark risk metrics calculation
|
|
Ok((None, None, None, None))
|
|
}
|
|
|
|
fn calculate_drawdowns(
|
|
&self,
|
|
) -> Result<(
|
|
Decimal,
|
|
Decimal,
|
|
Vec<DrawdownPeriod>,
|
|
Vec<(DateTime<Utc>, Decimal)>,
|
|
)> {
|
|
if self.snapshots.is_empty() {
|
|
return Ok((Decimal::ZERO, Decimal::ZERO, Vec::new(), Vec::new()));
|
|
}
|
|
|
|
let mut max_drawdown = Decimal::ZERO;
|
|
let mut peak = self.snapshots[0].portfolio_value;
|
|
let mut drawdown_periods = Vec::new();
|
|
let mut underwater_curve = Vec::new();
|
|
let mut in_drawdown = false;
|
|
let mut drawdown_start: Option<DateTime<Utc>> = None;
|
|
let mut drawdown_peak = Decimal::ZERO;
|
|
|
|
for snapshot in &self.snapshots {
|
|
if snapshot.portfolio_value > peak {
|
|
// New peak - end any current drawdown
|
|
if in_drawdown {
|
|
if let Some(start) = drawdown_start {
|
|
drawdown_periods.push(DrawdownPeriod {
|
|
start_date: start,
|
|
end_date: Some(snapshot.timestamp),
|
|
peak_value: drawdown_peak,
|
|
trough_value: peak, // This would be the actual trough
|
|
max_drawdown: (peak - drawdown_peak) / drawdown_peak,
|
|
duration: (snapshot.timestamp - start).num_days(),
|
|
recovery_date: Some(snapshot.timestamp),
|
|
});
|
|
}
|
|
in_drawdown = false;
|
|
}
|
|
peak = snapshot.portfolio_value;
|
|
}
|
|
|
|
let current_drawdown = (snapshot.portfolio_value - peak) / peak;
|
|
underwater_curve.push((snapshot.timestamp, current_drawdown));
|
|
|
|
if current_drawdown < Decimal::ZERO && !in_drawdown {
|
|
// Start of new drawdown
|
|
in_drawdown = true;
|
|
drawdown_start = Some(snapshot.timestamp);
|
|
drawdown_peak = peak;
|
|
}
|
|
|
|
if current_drawdown < max_drawdown {
|
|
max_drawdown = current_drawdown;
|
|
}
|
|
}
|
|
|
|
// Handle ongoing drawdown
|
|
if in_drawdown {
|
|
if let Some(start) = drawdown_start {
|
|
drawdown_periods.push(DrawdownPeriod {
|
|
start_date: start,
|
|
end_date: None,
|
|
peak_value: drawdown_peak,
|
|
trough_value: self
|
|
.snapshots
|
|
.last()
|
|
.map(|s| s.portfolio_value)
|
|
.unwrap_or(drawdown_peak),
|
|
max_drawdown: self
|
|
.snapshots
|
|
.last()
|
|
.map(|s| (s.portfolio_value - drawdown_peak) / drawdown_peak)
|
|
.unwrap_or(Decimal::ZERO),
|
|
duration: self
|
|
.snapshots
|
|
.last()
|
|
.map(|s| (s.timestamp - start).num_days())
|
|
.unwrap_or(0),
|
|
recovery_date: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
let current_drawdown = if let Some(last_snapshot) = self.snapshots.last() {
|
|
(last_snapshot.portfolio_value - peak) / peak
|
|
} else {
|
|
Decimal::ZERO
|
|
};
|
|
|
|
Ok((
|
|
max_drawdown,
|
|
current_drawdown,
|
|
drawdown_periods,
|
|
underwater_curve,
|
|
))
|
|
}
|
|
|
|
fn calculate_monthly_returns(&self) -> Result<Vec<Decimal>> {
|
|
// Implementation for monthly returns calculation
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn calculate_monthly_trade_count(&self) -> Vec<(DateTime<Utc>, u64)> {
|
|
// Implementation for monthly trade count calculation
|
|
Vec::new()
|
|
}
|
|
|
|
fn calculate_monthly_performance(&self) -> Result<Vec<MonthlyPerformance>> {
|
|
// Implementation for monthly performance calculation
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn calculate_yearly_performance(&self) -> Result<Vec<YearlyPerformance>> {
|
|
// Implementation for yearly performance calculation
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn calculate_skewness(&self, returns: &[f64]) -> Decimal {
|
|
if returns.len() < 3 {
|
|
return Decimal::ZERO;
|
|
}
|
|
|
|
let mean = returns.mean();
|
|
let std_dev = returns.std_dev();
|
|
|
|
if std_dev == 0.0 {
|
|
return Decimal::ZERO;
|
|
}
|
|
|
|
let n = returns.len() as f64;
|
|
let skewness = returns
|
|
.iter()
|
|
.map(|&x| ((x - mean) / std_dev).powi(3))
|
|
.sum::<f64>()
|
|
* n
|
|
/ ((n - 1.0) * (n - 2.0));
|
|
|
|
Decimal::from_f64_retain(skewness).unwrap_or_default()
|
|
}
|
|
|
|
fn calculate_kurtosis(&self, returns: &[f64]) -> Decimal {
|
|
if returns.len() < 4 {
|
|
return Decimal::ZERO;
|
|
}
|
|
|
|
let mean = returns.mean();
|
|
let std_dev = returns.std_dev();
|
|
|
|
if std_dev == 0.0 {
|
|
return Decimal::ZERO;
|
|
}
|
|
|
|
let n = returns.len() as f64;
|
|
let kurtosis = returns
|
|
.iter()
|
|
.map(|&x| ((x - mean) / std_dev).powi(4))
|
|
.sum::<f64>()
|
|
* n
|
|
* (n + 1.0)
|
|
/ ((n - 1.0) * (n - 2.0) * (n - 3.0))
|
|
- 3.0 * (n - 1.0) * (n - 1.0) / ((n - 2.0) * (n - 3.0));
|
|
|
|
Decimal::from_f64_retain(kurtosis).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
// Extension trait for Decimal power operations
|
|
trait DecimalPower {
|
|
fn powf(self, exp: f64) -> Decimal;
|
|
}
|
|
|
|
impl DecimalPower for Decimal {
|
|
fn powf(self, exp: f64) -> Decimal {
|
|
let base_f64 = self.to_string().parse::<f64>().unwrap_or(0.0);
|
|
let result = base_f64.powf(exp);
|
|
Decimal::from_f64_retain(result).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_metrics_calculator_creation() {
|
|
let calculator = MetricsCalculator::new(Decimal::new(2, 2)); // 2% risk-free rate
|
|
assert_eq!(calculator.risk_free_rate, Decimal::new(2, 2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_calculations() {
|
|
let calculator = MetricsCalculator::new(Decimal::new(2, 2));
|
|
|
|
// Should handle empty data gracefully
|
|
let returns = calculator.calculate_daily_returns().unwrap();
|
|
assert!(returns.is_empty());
|
|
|
|
let total_return = calculator.calculate_total_return().unwrap();
|
|
assert_eq!(total_return, Decimal::ZERO);
|
|
}
|
|
}
|