use serde::Serialize;
use crate::portfolio::Trade;
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct Metrics {
pub pnl: f64,
pub return_pct: f64,
pub sharpe: f64,
pub sortino: f64,
pub calmar: f64,
pub max_drawdown: f64,
pub win_rate: f64,
pub profit_factor: f64,
pub num_trades: usize,
}
pub fn compute(initial: f64, equity: &[f64], trades: &[Trade]) -> Metrics {
let final_equity = equity.last().copied().unwrap_or(initial);
let pnl = final_equity - initial;
let return_pct = if initial.abs() < f64::EPSILON {
0.0
} else {
pnl / initial * 100.0
};
let sharpe = sharpe_ratio(equity);
let sortino = sortino_ratio(equity);
let max_drawdown = max_drawdown_pct(equity);
let calmar = if max_drawdown.abs() < f64::EPSILON {
if return_pct > 0.0 {
f64::INFINITY
} else {
0.0
}
} else {
return_pct / max_drawdown
};
let wins = trades.iter().filter(|t| t.pnl > 0.0).count();
let win_rate = if trades.is_empty() {
0.0
} else {
wins as f64 / trades.len() as f64 * 100.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| -t.pnl).sum();
let profit_factor = if gross_loss.abs() < f64::EPSILON {
if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
}
} else {
gross_profit / gross_loss
};
Metrics {
pnl,
return_pct,
sharpe,
sortino,
calmar,
max_drawdown,
win_rate,
profit_factor,
num_trades: trades.len(),
}
}
fn bar_returns(equity: &[f64]) -> Vec<f64> {
equity
.windows(2)
.map(|w| {
if w[0].abs() < f64::EPSILON {
0.0
} else {
w[1] / w[0] - 1.0
}
})
.collect()
}
fn sharpe_ratio(equity: &[f64]) -> f64 {
if equity.len() < 2 {
return 0.0;
}
let returns = bar_returns(equity);
let n = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / n;
let var = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n;
let std = var.sqrt();
if std.abs() < f64::EPSILON {
0.0
} else {
mean / std
}
}
fn sortino_ratio(equity: &[f64]) -> f64 {
if equity.len() < 2 {
return 0.0;
}
let returns = bar_returns(equity);
let n = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / n;
let downside = returns.iter().map(|r| r.min(0.0).powi(2)).sum::<f64>() / n;
let dd = downside.sqrt();
if dd.abs() < f64::EPSILON {
0.0
} else {
mean / dd
}
}
fn max_drawdown_pct(equity: &[f64]) -> f64 {
let mut peak = f64::NEG_INFINITY;
let mut max_dd = 0.0;
for &e in equity {
if e > peak {
peak = e;
}
if peak > 0.0 {
let dd = (peak - e) / peak * 100.0;
if dd > max_dd {
max_dd = dd;
}
}
}
max_dd
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flat_equity_is_zero_metrics() {
let m = compute(1000.0, &[1000.0, 1000.0, 1000.0], &[]);
assert!((m.pnl).abs() < 1e-9);
assert!((m.sharpe).abs() < 1e-9);
assert!((m.max_drawdown).abs() < 1e-9);
}
#[test]
fn drawdown_and_return() {
let m = compute(100.0, &[100.0, 120.0, 90.0, 110.0], &[]);
assert!((m.return_pct - 10.0).abs() < 1e-9); assert!((m.max_drawdown - 25.0).abs() < 1e-9);
assert!((m.calmar - 10.0 / 25.0).abs() < 1e-9);
}
#[test]
fn sortino_only_penalises_downside() {
let up = compute(100.0, &[100.0, 110.0, 115.0], &[]);
assert!(up.sortino.abs() < 1e-9);
assert!(up.sharpe > 0.0);
let mixed = compute(100.0, &[100.0, 110.0, 99.0, 105.0], &[]);
assert!(mixed.sortino.is_finite());
}
#[test]
fn calmar_is_infinite_without_drawdown() {
let m = compute(100.0, &[100.0, 110.0, 120.0], &[]);
assert!(m.max_drawdown.abs() < 1e-9);
assert!(m.calmar.is_infinite() && m.calmar > 0.0);
}
}