507 lines
16 KiB
Rust
507 lines
16 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use chrono::{Datelike, NaiveDate};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::engine::DailyEquityPoint;
|
|
use crate::events::FillEvent;
|
|
use crate::portfolio::HoldingSummary;
|
|
|
|
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
|
const MONTHS_PER_YEAR: f64 = 12.0;
|
|
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct BacktestMetrics {
|
|
pub total_return: f64,
|
|
pub annual_return: f64,
|
|
pub sharpe: f64,
|
|
pub max_drawdown: f64,
|
|
pub win_rate: f64,
|
|
pub alpha: f64,
|
|
pub beta: f64,
|
|
pub benchmark_cumulative_return: f64,
|
|
pub benchmark_net_value: f64,
|
|
pub risk_free_rate: f64,
|
|
pub monthly_excess_win_rate: f64,
|
|
pub excess_cumulative_return: f64,
|
|
pub excess_annual_return: f64,
|
|
pub max_drawdown_duration_days: usize,
|
|
pub total_trade_days: usize,
|
|
pub sortino: f64,
|
|
pub information_ratio: f64,
|
|
pub tracking_error: f64,
|
|
pub volatility: f64,
|
|
pub excess_return: f64,
|
|
pub excess_sharpe: f64,
|
|
pub excess_volatility: f64,
|
|
pub excess_max_drawdown: f64,
|
|
pub holding_count: usize,
|
|
pub average_weight: f64,
|
|
pub max_weight: f64,
|
|
pub concentration: f64,
|
|
pub weight_std_dev: f64,
|
|
pub median_weight: f64,
|
|
pub average_daily_turnover: f64,
|
|
pub total_assets: f64,
|
|
pub cash_balance: f64,
|
|
pub unit_nav: f64,
|
|
pub initial_cash: f64,
|
|
pub excess_win_rate: f64,
|
|
pub monthly_sharpe: f64,
|
|
pub monthly_volatility: f64,
|
|
}
|
|
|
|
pub fn compute_backtest_metrics(
|
|
equity_curve: &[DailyEquityPoint],
|
|
fills: &[FillEvent],
|
|
daily_holdings: &[HoldingSummary],
|
|
initial_cash: f64,
|
|
) -> BacktestMetrics {
|
|
let Some(first_point) = equity_curve.first() else {
|
|
return BacktestMetrics {
|
|
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
|
initial_cash,
|
|
..BacktestMetrics::default()
|
|
};
|
|
};
|
|
let Some(last_point) = equity_curve.last() else {
|
|
return BacktestMetrics {
|
|
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
|
initial_cash,
|
|
..BacktestMetrics::default()
|
|
};
|
|
};
|
|
|
|
let trade_days = equity_curve.len();
|
|
let benchmark_start = if first_point.benchmark_prev_close.is_finite()
|
|
&& first_point.benchmark_prev_close > f64::EPSILON
|
|
{
|
|
first_point.benchmark_prev_close
|
|
} else {
|
|
first_point.benchmark_close
|
|
};
|
|
let mut returns = Vec::with_capacity(equity_curve.len());
|
|
returns.push(pct_change(initial_cash, first_point.total_equity));
|
|
returns.extend(
|
|
equity_curve
|
|
.windows(2)
|
|
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
|
|
);
|
|
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
|
|
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
|
|
benchmark_returns.extend(
|
|
equity_curve
|
|
.windows(2)
|
|
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close)),
|
|
);
|
|
let excess_returns = returns
|
|
.iter()
|
|
.zip(benchmark_returns.iter())
|
|
.map(|(lhs, rhs)| lhs - rhs)
|
|
.collect::<Vec<_>>();
|
|
|
|
let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON {
|
|
1.0
|
|
} else {
|
|
last_point.benchmark_close / benchmark_start
|
|
};
|
|
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
|
let total_return = if initial_cash.abs() < f64::EPSILON {
|
|
0.0
|
|
} else {
|
|
(last_point.total_equity / initial_cash) - 1.0
|
|
};
|
|
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
|
total_return
|
|
} else {
|
|
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
|
};
|
|
let excess_return = total_return - benchmark_cumulative_return;
|
|
let annual_return = annualize_return(total_return, trade_days);
|
|
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
|
|
|
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
|
|
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
|
|
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
|
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
|
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
|
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
|
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
|
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
|
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
|
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
|
|
|
let equity_nav = equity_curve
|
|
.iter()
|
|
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
|
.collect::<Vec<_>>();
|
|
let benchmark_nav_series = equity_curve
|
|
.iter()
|
|
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
|
.collect::<Vec<_>>();
|
|
let excess_nav_series = equity_nav
|
|
.iter()
|
|
.zip(benchmark_nav_series.iter())
|
|
.map(|(lhs, rhs)| safe_div(*lhs, *rhs, *lhs))
|
|
.collect::<Vec<_>>();
|
|
|
|
let (max_drawdown, max_drawdown_duration_days) = drawdown_stats(&equity_nav);
|
|
let (excess_max_drawdown, _) = drawdown_stats(&excess_nav_series);
|
|
|
|
let winning_days = returns.iter().filter(|value| **value > 0.0).count();
|
|
let excess_winning_days = excess_returns.iter().filter(|value| **value > 0.0).count();
|
|
let win_rate = ratio(winning_days, returns.len());
|
|
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
|
|
|
let monthly_portfolio_returns =
|
|
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
|
|
let monthly_benchmark_returns =
|
|
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
|
let monthly_excess_returns = monthly_portfolio_returns
|
|
.iter()
|
|
.zip(monthly_benchmark_returns.iter())
|
|
.map(|(lhs, rhs)| lhs - rhs)
|
|
.collect::<Vec<_>>();
|
|
let monthly_excess_win_rate = ratio(
|
|
monthly_excess_returns
|
|
.iter()
|
|
.filter(|value| **value > 0.0)
|
|
.count(),
|
|
monthly_excess_returns.len(),
|
|
);
|
|
let monthly_sharpe = annualized_sharpe(
|
|
&monthly_portfolio_returns,
|
|
risk_free_rate / MONTHS_PER_YEAR,
|
|
MONTHS_PER_YEAR,
|
|
);
|
|
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
|
|
|
let turnover_by_date = fills
|
|
.iter()
|
|
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
|
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
|
acc
|
|
});
|
|
let equity_by_date = equity_curve
|
|
.iter()
|
|
.map(|point| (point.date, point.total_equity))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let average_daily_turnover = if equity_curve.is_empty() {
|
|
0.0
|
|
} else {
|
|
equity_curve
|
|
.iter()
|
|
.map(|point| {
|
|
let traded = turnover_by_date
|
|
.get(&point.date)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0)
|
|
})
|
|
.sum::<f64>()
|
|
/ equity_curve.len() as f64
|
|
};
|
|
|
|
let latest_date = last_point.date;
|
|
let latest_holdings = daily_holdings
|
|
.iter()
|
|
.filter(|row| row.date == latest_date && row.quantity > 0)
|
|
.collect::<Vec<_>>();
|
|
let weights = latest_holdings
|
|
.iter()
|
|
.map(|holding| safe_div(holding.market_value, last_point.total_equity, 0.0))
|
|
.collect::<Vec<_>>();
|
|
let holding_count = latest_holdings.len();
|
|
let average_weight = mean(&weights);
|
|
let max_weight = weights
|
|
.iter()
|
|
.copied()
|
|
.fold(0.0_f64, |acc, value| acc.max(value));
|
|
let concentration = weights.iter().map(|weight| weight * weight).sum::<f64>();
|
|
let weight_std_dev = std_dev(&weights);
|
|
let median_weight = median(&weights);
|
|
|
|
let total_trade_days = equity_by_date.len();
|
|
|
|
BacktestMetrics {
|
|
total_return,
|
|
annual_return,
|
|
sharpe,
|
|
max_drawdown,
|
|
win_rate,
|
|
alpha,
|
|
beta,
|
|
benchmark_cumulative_return,
|
|
benchmark_net_value,
|
|
risk_free_rate,
|
|
monthly_excess_win_rate,
|
|
excess_cumulative_return,
|
|
excess_annual_return,
|
|
max_drawdown_duration_days,
|
|
total_trade_days,
|
|
sortino,
|
|
information_ratio,
|
|
tracking_error,
|
|
volatility,
|
|
excess_return,
|
|
excess_sharpe,
|
|
excess_volatility,
|
|
excess_max_drawdown,
|
|
holding_count,
|
|
average_weight,
|
|
max_weight,
|
|
concentration,
|
|
weight_std_dev,
|
|
median_weight,
|
|
average_daily_turnover,
|
|
total_assets: last_point.total_equity,
|
|
cash_balance: last_point.cash,
|
|
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
|
initial_cash,
|
|
excess_win_rate,
|
|
monthly_sharpe,
|
|
monthly_volatility,
|
|
}
|
|
}
|
|
|
|
fn pct_change(previous: f64, current: f64) -> f64 {
|
|
if previous.abs() < f64::EPSILON {
|
|
0.0
|
|
} else {
|
|
(current / previous) - 1.0
|
|
}
|
|
}
|
|
|
|
fn annualize_return(total_return: f64, periods: usize) -> f64 {
|
|
if periods == 0 {
|
|
return 0.0;
|
|
}
|
|
let periods = periods as f64;
|
|
let base = 1.0 + total_return;
|
|
if base <= 0.0 {
|
|
return -1.0;
|
|
}
|
|
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
|
}
|
|
|
|
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
|
if returns.len() < 2 {
|
|
return 0.0;
|
|
}
|
|
let adjusted = returns
|
|
.iter()
|
|
.map(|value| value - daily_rf)
|
|
.collect::<Vec<_>>();
|
|
let mean_ret = mean(&adjusted);
|
|
let std = std_dev(&adjusted);
|
|
if std <= f64::EPSILON {
|
|
0.0
|
|
} else {
|
|
mean_ret / std * periods_per_year.sqrt()
|
|
}
|
|
}
|
|
|
|
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
|
if returns.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let adjusted = returns
|
|
.iter()
|
|
.map(|value| value - daily_rf)
|
|
.collect::<Vec<_>>();
|
|
let downside = adjusted
|
|
.iter()
|
|
.filter(|value| **value < 0.0)
|
|
.map(|value| value.powi(2))
|
|
.collect::<Vec<_>>();
|
|
if downside.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
|
|
if downside_dev <= f64::EPSILON {
|
|
0.0
|
|
} else {
|
|
mean(&adjusted) / downside_dev * periods_per_year.sqrt()
|
|
}
|
|
}
|
|
|
|
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
|
std_dev(values) * periods_per_year.sqrt()
|
|
}
|
|
|
|
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
|
|
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
|
return (0.0, 0.0);
|
|
}
|
|
let strategy_excess = returns
|
|
.iter()
|
|
.map(|value| value - daily_rf)
|
|
.collect::<Vec<_>>();
|
|
let benchmark_excess = benchmark_returns
|
|
.iter()
|
|
.map(|value| value - daily_rf)
|
|
.collect::<Vec<_>>();
|
|
let mean_strategy = mean(&strategy_excess);
|
|
let mean_benchmark = mean(&benchmark_excess);
|
|
let variance_benchmark = variance(&benchmark_excess);
|
|
if variance_benchmark <= f64::EPSILON {
|
|
return (0.0, 0.0);
|
|
}
|
|
let covariance = strategy_excess
|
|
.iter()
|
|
.zip(benchmark_excess.iter())
|
|
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
|
|
.sum::<f64>()
|
|
/ (strategy_excess.len() - 1) as f64;
|
|
let beta = covariance / variance_benchmark;
|
|
let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR;
|
|
(alpha, beta)
|
|
}
|
|
|
|
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
|
let mut peak = 0.0_f64;
|
|
let mut max_drawdown = 0.0_f64;
|
|
let mut duration = 0_usize;
|
|
let mut max_duration = 0_usize;
|
|
for value in nav {
|
|
if *value >= peak {
|
|
peak = *value;
|
|
duration = 0;
|
|
continue;
|
|
}
|
|
if peak > f64::EPSILON {
|
|
let drawdown = (*value / peak) - 1.0;
|
|
if drawdown < max_drawdown {
|
|
max_drawdown = drawdown;
|
|
}
|
|
}
|
|
duration += 1;
|
|
if duration > max_duration {
|
|
max_duration = duration;
|
|
}
|
|
}
|
|
(max_drawdown, max_duration)
|
|
}
|
|
|
|
fn group_monthly_returns<F>(
|
|
equity_curve: &[DailyEquityPoint],
|
|
initial_value: f64,
|
|
value_fn: F,
|
|
) -> Vec<f64>
|
|
where
|
|
F: Fn(&DailyEquityPoint) -> f64,
|
|
{
|
|
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
|
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
|
let mut previous_value = initial_value;
|
|
for point in equity_curve {
|
|
let key = (point.date.year(), point.date.month());
|
|
let value = value_fn(point);
|
|
month_first.entry(key).or_insert(previous_value);
|
|
month_last.insert(key, value);
|
|
previous_value = value;
|
|
}
|
|
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
|
|
keys.sort_unstable();
|
|
keys.into_iter()
|
|
.filter_map(|key| {
|
|
let first = month_first.get(&key).copied().unwrap_or_default();
|
|
let last = month_last.get(&key).copied().unwrap_or_default();
|
|
if first.abs() < f64::EPSILON {
|
|
None
|
|
} else {
|
|
Some((last / first) - 1.0)
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn mean(values: &[f64]) -> f64 {
|
|
if values.is_empty() {
|
|
0.0
|
|
} else {
|
|
values.iter().sum::<f64>() / values.len() as f64
|
|
}
|
|
}
|
|
|
|
fn variance(values: &[f64]) -> f64 {
|
|
if values.len() < 2 {
|
|
return 0.0;
|
|
}
|
|
let avg = mean(values);
|
|
values
|
|
.iter()
|
|
.map(|value| (value - avg).powi(2))
|
|
.sum::<f64>()
|
|
/ (values.len() - 1) as f64
|
|
}
|
|
|
|
fn std_dev(values: &[f64]) -> f64 {
|
|
variance(values).sqrt()
|
|
}
|
|
|
|
fn median(values: &[f64]) -> f64 {
|
|
if values.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let mut sorted = values.to_vec();
|
|
sorted.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).unwrap_or(std::cmp::Ordering::Equal));
|
|
let mid = sorted.len() / 2;
|
|
if sorted.len() % 2 == 0 {
|
|
(sorted[mid - 1] + sorted[mid]) / 2.0
|
|
} else {
|
|
sorted[mid]
|
|
}
|
|
}
|
|
|
|
fn ratio(numerator: usize, denominator: usize) -> f64 {
|
|
if denominator == 0 {
|
|
0.0
|
|
} else {
|
|
numerator as f64 / denominator as f64
|
|
}
|
|
}
|
|
|
|
fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
|
if denominator.abs() < f64::EPSILON {
|
|
fallback
|
|
} else {
|
|
numerator / denominator
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn equity_point(
|
|
date: &str,
|
|
total_equity: f64,
|
|
benchmark_close: f64,
|
|
benchmark_prev_close: f64,
|
|
) -> DailyEquityPoint {
|
|
DailyEquityPoint {
|
|
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
|
|
cash: total_equity,
|
|
market_value: 0.0,
|
|
total_equity,
|
|
benchmark_close,
|
|
benchmark_prev_close,
|
|
notes: String::new(),
|
|
diagnostics: String::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn benchmark_cumulative_return_uses_first_day_previous_close() {
|
|
let curve = vec![
|
|
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
|
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
|
];
|
|
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
|
|
let expected = 7595.285 / 5957.717 - 1.0;
|
|
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
|
}
|
|
}
|