use chrono::NaiveDate;
pub fn to_returns(equity: &[f64]) -> Vec<f64> {
let mut out = vec![f64::NAN; equity.len()];
for i in 1..equity.len() {
out[i] = equity[i] / equity[i - 1] - 1.0;
}
out
}
pub fn total_return(equity: &[f64]) -> f64 {
if equity.is_empty() {
return f64::NAN;
}
equity[equity.len() - 1] / equity[0] - 1.0
}
pub fn drawdown_series(equity: &[f64]) -> Vec<f64> {
let mut out = vec![0.0; equity.len()];
let mut peak = f64::NEG_INFINITY;
for (i, &e) in equity.iter().enumerate() {
if e > peak {
peak = e;
}
out[i] = e / peak - 1.0;
}
out
}
pub fn max_drawdown(equity: &[f64]) -> f64 {
if equity.is_empty() {
return f64::NAN;
}
drawdown_series(equity).into_iter().fold(0.0, f64::min)
}
fn to_naive(yyyymmdd: i32) -> NaiveDate {
let y = yyyymmdd / 10000;
let m = (yyyymmdd / 100 % 100) as u32;
let d = (yyyymmdd % 100) as u32;
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
pub fn year_frac(start: i32, end: i32) -> f64 {
let secs = (to_naive(end) - to_naive(start)).num_seconds() as f64;
secs / 31_557_600.0
}
fn mean_std(xs: &[f64]) -> (f64, f64) {
let v: Vec<f64> = xs.iter().copied().filter(|x| !x.is_nan()).collect();
let n = v.len() as f64;
if n == 0.0 {
return (f64::NAN, f64::NAN);
}
let mean = v.iter().sum::<f64>() / n;
if n < 2.0 {
return (mean, f64::NAN);
}
let var = v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
(mean, var.sqrt())
}
pub fn cagr(equity: &[f64], dates: &[i32]) -> f64 {
if equity.len() < 2 || dates.len() < 2 {
return f64::NAN;
}
let yf = year_frac(dates[0], dates[dates.len() - 1]);
(equity[equity.len() - 1] / equity[0]).powf(1.0 / yf) - 1.0
}
pub fn ann_volatility(equity: &[f64]) -> f64 {
let (_, std) = mean_std(&to_returns(equity));
std * 252.0_f64.sqrt()
}
pub fn sharpe(equity: &[f64]) -> f64 {
let r = to_returns(equity);
let (mean, std) = mean_std(&r);
(mean / std.max(1e-6)) * 252.0_f64.sqrt()
}
pub fn sortino(equity: &[f64]) -> f64 {
let r = to_returns(equity);
let (mean, _) = mean_std(&r);
let downside: Vec<f64> = r
.iter()
.skip(1)
.map(|&x| if x < 0.0 { x } else { 0.0 })
.collect();
let (_, dstd) = mean_std(&downside);
if dstd <= 0.0 || dstd.is_nan() {
return f64::NAN;
}
(mean / dstd) * 252.0_f64.sqrt()
}
pub fn calmar(equity: &[f64], dates: &[i32]) -> f64 {
let c = cagr(equity, dates);
if c.is_nan() {
return f64::NAN;
}
let mdd = max_drawdown(equity).abs();
if mdd == 0.0 {
return f64::INFINITY;
}
c / mdd
}
pub fn recovery_factor(equity: &[f64]) -> f64 {
let mdd = max_drawdown(equity).abs();
if mdd == 0.0 {
return f64::INFINITY;
}
total_return(equity) / mdd
}
pub fn max_drawdown_duration(equity: &[f64]) -> f64 {
let (mut max, mut cur) = (0u32, 0u32);
for d in drawdown_series(equity) {
if d < 0.0 {
cur += 1;
max = max.max(cur);
} else {
cur = 0;
}
}
max as f64
}
use crate::backtest::Trade;
fn closed(trades: &[Trade]) -> Vec<&Trade> {
trades.iter().filter(|t| t.exit_date.is_some()).collect()
}
pub fn win_rate(trades: &[Trade]) -> f64 {
let c = closed(trades);
if c.is_empty() {
return f64::NAN;
}
c.iter().filter(|t| t.ret > 0.0).count() as f64 / c.len() as f64
}
pub fn profit_factor(trades: &[Trade]) -> f64 {
let c = closed(trades);
if c.is_empty() {
return f64::NAN;
}
let gains: f64 = c.iter().filter(|t| t.ret > 0.0).map(|t| t.ret).sum();
let losses: f64 = c.iter().filter(|t| t.ret < 0.0).map(|t| t.ret).sum();
if losses == 0.0 {
return f64::INFINITY;
}
gains / losses.abs()
}
pub fn expectancy(trades: &[Trade]) -> f64 {
let c = closed(trades);
if c.is_empty() {
return f64::NAN;
}
c.iter().map(|t| t.ret).sum::<f64>() / c.len() as f64
}
pub fn avg_holding_period(trades: &[Trade]) -> f64 {
let c = closed(trades);
if c.is_empty() {
return f64::NAN;
}
c.iter().map(|t| t.period as f64).sum::<f64>() / c.len() as f64
}
pub fn num_trades(trades: &[Trade]) -> f64 {
closed(trades).len() as f64
}
pub fn avg_win(trades: &[Trade]) -> f64 {
let w: Vec<f64> = closed(trades)
.iter()
.map(|t| t.ret)
.filter(|&r| r > 0.0)
.collect();
if w.is_empty() {
return f64::NAN;
}
w.iter().sum::<f64>() / w.len() as f64
}
pub fn avg_loss(trades: &[Trade]) -> f64 {
let l: Vec<f64> = closed(trades)
.iter()
.map(|t| t.ret)
.filter(|&r| r < 0.0)
.collect();
if l.is_empty() {
return f64::NAN;
}
l.iter().sum::<f64>() / l.len() as f64
}
pub fn payoff_ratio(trades: &[Trade]) -> f64 {
let (aw, al) = (avg_win(trades), avg_loss(trades));
if aw.is_nan() || al.is_nan() {
return f64::NAN;
}
aw / al.abs()
}
pub fn best_trade(trades: &[Trade]) -> f64 {
let c = closed(trades);
if c.is_empty() {
return f64::NAN;
}
c.iter().map(|t| t.ret).fold(f64::NEG_INFINITY, f64::max)
}
pub fn worst_trade(trades: &[Trade]) -> f64 {
let c = closed(trades);
if c.is_empty() {
return f64::NAN;
}
c.iter().map(|t| t.ret).fold(f64::INFINITY, f64::min)
}
pub fn max_consecutive_losses(trades: &[Trade]) -> f64 {
let mut c = closed(trades);
c.sort_by_key(|t| t.exit_date); let (mut max, mut cur) = (0u32, 0u32);
for t in c {
if t.ret < 0.0 {
cur += 1;
max = max.max(cur);
} else {
cur = 0;
}
}
max as f64
}
pub fn time_in_market(exposure: &[f64]) -> f64 {
if exposure.is_empty() {
return f64::NAN;
}
exposure.iter().filter(|&&e| e > 0.0).count() as f64 / exposure.len() as f64
}
pub fn avg_exposure(exposure: &[f64]) -> f64 {
if exposure.is_empty() {
return f64::NAN;
}
exposure.iter().sum::<f64>() / exposure.len() as f64
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PeriodReturn {
pub period: String,
pub ret: f64,
}
fn period_returns(dates: &[i32], equity: &[f64], monthly: bool) -> Vec<PeriodReturn> {
let key = |d: i32| if monthly { d / 100 } else { d / 10000 };
let label = |k: i32| {
if monthly {
format!("{}-{:02}", k / 100, k % 100)
} else {
k.to_string()
}
};
let mut out = Vec::new();
if dates.is_empty() || equity.len() != dates.len() {
return out;
}
let mut baseline = equity[0];
let mut cur = key(dates[0]);
for i in 0..dates.len() {
let k = key(dates[i]);
if k != cur {
out.push(PeriodReturn {
period: label(cur),
ret: equity[i - 1] / baseline - 1.0,
});
baseline = equity[i - 1];
cur = k;
}
}
out.push(PeriodReturn {
period: label(cur),
ret: equity[equity.len() - 1] / baseline - 1.0,
});
out
}
pub fn monthly_returns(dates: &[i32], equity: &[f64]) -> Vec<PeriodReturn> {
period_returns(dates, equity, true)
}
pub fn yearly_returns(dates: &[i32], equity: &[f64]) -> Vec<PeriodReturn> {
period_returns(dates, equity, false)
}
pub fn rolling_volatility(equity: &[f64], window: usize) -> Vec<f64> {
let r = to_returns(equity);
let mut out = vec![f64::NAN; r.len()];
for i in window..r.len() {
let (_, std) = mean_std(&r[i + 1 - window..=i]);
out[i] = std * 252.0_f64.sqrt();
}
out
}
pub fn rolling_sharpe(equity: &[f64], window: usize) -> Vec<f64> {
let r = to_returns(equity);
let mut out = vec![f64::NAN; r.len()];
for i in window..r.len() {
let (mean, std) = mean_std(&r[i + 1 - window..=i]);
out[i] = (mean / std.max(1e-6)) * 252.0_f64.sqrt();
}
out
}
fn paired_returns(equity: &[f64], bench: &[f64]) -> (Vec<f64>, Vec<f64>) {
let r = to_returns(equity);
let b = to_returns(bench);
let mut rs = Vec::new();
let mut bs = Vec::new();
for i in 0..r.len().min(b.len()) {
if !r[i].is_nan() && !b[i].is_nan() {
rs.push(r[i]);
bs.push(b[i]);
}
}
(rs, bs)
}
pub fn beta(equity: &[f64], bench: &[f64]) -> f64 {
let (rs, bs) = paired_returns(equity, bench);
let n = rs.len() as f64;
if n < 2.0 {
return f64::NAN;
}
let (rm, _) = mean_std(&rs);
let (bm, bstd) = mean_std(&bs);
let cov = rs
.iter()
.zip(&bs)
.map(|(r, b)| (r - rm) * (b - bm))
.sum::<f64>()
/ (n - 1.0);
let var = bstd * bstd;
if var == 0.0 {
return f64::NAN;
}
cov / var
}
pub fn alpha(equity: &[f64], bench: &[f64]) -> f64 {
let (rs, bs) = paired_returns(equity, bench);
let beta = beta(equity, bench);
if beta.is_nan() {
return f64::NAN;
}
let (rm, _) = mean_std(&rs);
let (bm, _) = mean_std(&bs);
(rm - beta * bm) * 252.0
}
pub fn tracking_error(equity: &[f64], bench: &[f64]) -> f64 {
let (rs, bs) = paired_returns(equity, bench);
let diff: Vec<f64> = rs.iter().zip(&bs).map(|(r, b)| r - b).collect();
let (_, std) = mean_std(&diff);
std * 252.0_f64.sqrt()
}
pub fn information_ratio(equity: &[f64], bench: &[f64]) -> f64 {
let (rs, bs) = paired_returns(equity, bench);
let diff: Vec<f64> = rs.iter().zip(&bs).map(|(r, b)| r - b).collect();
let (mean, std) = mean_std(&diff);
(mean / std.max(1e-6)) * 252.0_f64.sqrt()
}
pub fn benchmark_return(bench: &[f64]) -> f64 {
let first = bench.iter().copied().find(|x| !x.is_nan());
let last = bench.iter().rev().copied().find(|x| !x.is_nan());
match (first, last) {
(Some(f), Some(l)) if f != 0.0 => l / f - 1.0,
_ => f64::NAN,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn period_returns_bucket_by_month_and_year() {
let dates = [20231228, 20231229, 20240102, 20240131, 20240201];
let eq = [1.0, 1.1, 1.1, 1.32, 1.32];
let m = monthly_returns(&dates, &eq);
assert_eq!(m.len(), 3);
assert_eq!(m[0].period, "2023-12");
assert!((m[0].ret - 0.1).abs() < 1e-12);
assert_eq!(m[1].period, "2024-01");
assert!((m[1].ret - 0.2).abs() < 1e-12); assert_eq!(m[2].period, "2024-02");
assert!(m[2].ret.abs() < 1e-12);
let y = yearly_returns(&dates, &eq);
assert_eq!(y.len(), 2);
assert_eq!(y[0].period, "2023");
assert!((y[0].ret - 0.1).abs() < 1e-12);
assert_eq!(y[1].period, "2024");
assert!((y[1].ret - 0.2).abs() < 1e-12);
}
#[test]
fn rolling_metrics_warm_up_then_fill() {
let mut eq = vec![1.0];
for _ in 0..5 {
let prev = *eq.last().unwrap();
eq.push(prev * 1.01);
}
let vol = rolling_volatility(&eq, 3);
let sh = rolling_sharpe(&eq, 3);
assert!(vol[2].is_nan() && sh[2].is_nan());
assert!(vol[3].abs() < 1e-9, "constant returns -> zero vol");
assert!(sh[3] > 0.0);
assert_eq!(vol.len(), eq.len());
}
#[test]
fn benchmark_relative_metrics() {
let bench = [1.0, 1.01, 1.0201, 1.01, 1.0201];
let mut eq = vec![1.0];
for i in 1..bench.len() {
let b = bench[i] / bench[i - 1] - 1.0;
let prev = *eq.last().unwrap();
eq.push(prev * (1.0 + 2.0 * b));
}
assert!((beta(&eq, &bench) - 2.0).abs() < 1e-9, "beta");
assert!(alpha(&eq, &bench).abs() < 1e-9, "alpha ~ 0");
assert!((beta(&bench, &bench) - 1.0).abs() < 1e-12);
assert!(tracking_error(&bench, &bench).abs() < 1e-12);
assert!(information_ratio(&bench, &bench).abs() < 1e-9);
let flat = [1.0, 1.0, 1.0];
assert!(beta(&bench[..3], &flat).is_nan());
let with_nan = [f64::NAN, 1.0, 1.1, f64::NAN];
assert!((benchmark_return(&with_nan) - 0.1).abs() < 1e-12);
assert!(benchmark_return(&[f64::NAN]).is_nan());
}
#[test]
fn returns_drawdown_and_totals() {
let eq = [1.0, 1.02, 1.01, 1.05];
let r = to_returns(&eq);
assert!(r[0].is_nan());
assert!((r[1] - 0.02).abs() < 1e-12);
assert!((total_return(&eq) - 0.05).abs() < 1e-12);
let dd = drawdown_series(&eq);
assert_eq!(dd[0], 0.0);
assert!((dd[2] - (1.01 / 1.02 - 1.0)).abs() < 1e-12);
assert!((max_drawdown(&eq) - (1.01 / 1.02 - 1.0)).abs() < 1e-12);
}
#[test]
fn empty_and_single_inputs() {
assert!(max_drawdown(&[]).is_nan());
assert!(total_return(&[]).is_nan());
let one = to_returns(&[1.0]);
assert_eq!(one.len(), 1);
assert!(one[0].is_nan());
assert_eq!(drawdown_series(&[1.0]), vec![0.0]);
}
#[test]
fn cagr_and_calmar_guard_short_input() {
assert!(cagr(&[1.0], &[20240102]).is_nan());
assert!(calmar(&[1.0], &[20240102]).is_nan());
}
#[test]
fn calmar_guards_zero_drawdown() {
assert!(calmar(&[1.0, 1.0, 1.0], &[20240102, 20240103, 20240104]).is_infinite());
}
#[test]
fn trade_level_metrics() {
use crate::backtest::Trade;
let t = |ret: f64, period: u32| Trade {
symbol: "X".into(),
entry_date: 20240102,
exit_date: Some(20240105),
ret,
period,
mae: None,
mfe: None,
};
let trades = vec![t(0.10, 3), t(-0.05, 2), t(0.20, 5)];
assert!((win_rate(&trades) - 2.0 / 3.0).abs() < 1e-12);
assert!((profit_factor(&trades) - (0.30 / 0.05)).abs() < 1e-12);
assert!((expectancy(&trades) - (0.25 / 3.0)).abs() < 1e-12);
assert!((avg_holding_period(&trades) - (10.0 / 3.0)).abs() < 1e-12);
}
#[test]
fn extended_trade_level_metrics() {
use crate::backtest::Trade;
let t = |ret: f64, exit: i32| Trade {
symbol: "X".into(),
entry_date: 20240102,
exit_date: Some(exit),
ret,
period: 1,
mae: None,
mfe: None,
};
let trades = vec![
t(0.10, 20240105),
t(-0.05, 20240106),
t(-0.20, 20240107),
t(0.30, 20240108),
t(-0.10, 20240109),
];
assert_eq!(num_trades(&trades), 5.0);
assert!((avg_win(&trades) - (0.40 / 2.0)).abs() < 1e-12); assert!((avg_loss(&trades) - (-0.35 / 3.0)).abs() < 1e-12); assert!((payoff_ratio(&trades) - (0.20 / (0.35 / 3.0))).abs() < 1e-12);
assert!((best_trade(&trades) - 0.30).abs() < 1e-12);
assert!((worst_trade(&trades) + 0.20).abs() < 1e-12);
assert_eq!(max_consecutive_losses(&trades), 2.0);
}
#[test]
fn trade_metrics_handle_empty_and_one_sided() {
use crate::backtest::Trade;
let win = vec![Trade {
symbol: "X".into(),
entry_date: 20240102,
exit_date: Some(20240103),
ret: 0.1,
period: 1,
mae: None,
mfe: None,
}];
assert_eq!(num_trades(&[]), 0.0);
assert!(avg_win(&[]).is_nan());
assert!(avg_loss(&win).is_nan()); assert!(payoff_ratio(&win).is_nan()); assert!(best_trade(&[]).is_nan());
assert_eq!(max_consecutive_losses(&win), 0.0);
}
#[test]
fn max_consecutive_losses_sorts_by_exit_date() {
use crate::backtest::Trade;
let t = |ret: f64, exit: i32| Trade {
symbol: "X".into(),
entry_date: 20240102,
exit_date: Some(exit),
ret,
period: 1,
mae: None,
mfe: None,
};
let trades = vec![t(-0.1, 20240105), t(0.2, 20240103), t(-0.1, 20240104)];
assert_eq!(max_consecutive_losses(&trades), 2.0);
}
#[test]
fn equity_and_exposure_metrics() {
let eq = [1.0, 0.9, 0.8, 0.9, 1.0];
assert!((recovery_factor(&eq) - 0.0).abs() < 1e-12);
assert_eq!(max_drawdown_duration(&eq), 3.0);
assert!(recovery_factor(&[1.0, 1.1, 1.2]).is_infinite());
let exposure = [1.0, 0.0, 0.5, 0.5];
assert!((time_in_market(&exposure) - 0.75).abs() < 1e-12); assert!((avg_exposure(&exposure) - 0.5).abs() < 1e-12); assert!(time_in_market(&[]).is_nan());
assert!(avg_exposure(&[]).is_nan());
}
}