Files
fidc-backtest-engine/crates/fidc-core/src/metrics.rs
T
2026-09-03 15:02:44 +08:00

894 lines
30 KiB
Rust

use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use crate::engine::DailyEquityPoint;
use crate::events::{AccountEvent, FillEvent};
use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
const MONTHS_PER_YEAR: f64 = 12.0;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RiskFreeRateObservation {
pub date: NaiveDate,
pub source_date: NaiveDate,
pub annual_rate: f64,
pub daily_rate: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RiskFreeRateContract {
pub version: String,
pub source: String,
pub tenor: String,
pub periods_per_year: f64,
pub max_staleness_days: usize,
pub observed_max_staleness_days: usize,
pub sha256: String,
pub observations: Vec<RiskFreeRateObservation>,
}
#[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 downside_risk: 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,
/// Sum of external deposits (positive) and withdrawals (negative). This
/// is reported separately so callers cannot mistake a cash transfer for
/// trading performance.
#[serde(default)]
pub external_cash_flow_total: f64,
pub excess_win_rate: f64,
pub monthly_sharpe: f64,
pub monthly_volatility: f64,
pub risk_free_rate_contract_version: String,
pub risk_free_rate_source: String,
pub risk_free_rate_tenor: String,
pub risk_free_rate_observation_count: usize,
pub risk_free_rate_max_staleness_days: usize,
pub risk_free_rate_observed_max_staleness_days: usize,
pub risk_free_rate_sha256: String,
}
pub fn compute_backtest_metrics(
equity_curve: &[DailyEquityPoint],
fills: &[FillEvent],
daily_holdings: &[HoldingSummary],
account_events: &[AccountEvent],
initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
let Some(first_point) = equity_curve.first() else {
return Ok(BacktestMetrics {
initial_cash,
..BacktestMetrics::default()
});
};
let Some(last_point) = equity_curve.last() else {
return Ok(BacktestMetrics {
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 explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
&& point.unit_nav > 0.0
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
});
let portfolio_nav = if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point_nav(point, initial_cash))
.collect::<Vec<_>>()
} else {
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
};
let mut returns = Vec::with_capacity(portfolio_nav.len());
if let Some(first_nav) = portfolio_nav.first().copied() {
returns.push(pct_change(1.0, first_nav));
}
returns.extend(
portfolio_nav
.windows(2)
.map(|window| pct_change(window[0], window[1])),
);
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 zero_risk_free_rates = vec![0.0; excess_returns.len()];
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 final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
let total_return = final_nav - 1.0;
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
total_return
} else {
portfolio_nav.last().copied().unwrap_or(0.0) / 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 (daily_risk_free_rates, risk_free_metadata) =
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
let risk_free_rate =
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let sharpe = annualized_sharpe(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let sortino = annualized_sortino(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let downside_risk =
annualized_downside_risk(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
let information_ratio = annualized_sharpe(
&excess_returns,
&zero_risk_free_rates,
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,
&zero_risk_free_rates,
TRADING_DAYS_PER_YEAR,
);
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates);
let equity_nav = portfolio_nav;
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_from_values(equity_curve, &equity_nav);
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_risk_free_returns =
group_monthly_risk_free_returns(equity_curve, &daily_risk_free_rates);
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,
&monthly_risk_free_returns,
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();
Ok(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,
downside_risk,
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: final_nav,
initial_cash,
external_cash_flow_total: if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point.external_cash_flow)
.sum()
} else {
external_flow_total_from_events(account_events)
},
excess_win_rate,
monthly_sharpe,
monthly_volatility,
risk_free_rate_contract_version: risk_free_metadata.version,
risk_free_rate_source: risk_free_metadata.source,
risk_free_rate_tenor: risk_free_metadata.tenor,
risk_free_rate_observation_count: daily_risk_free_rates.len(),
risk_free_rate_max_staleness_days: risk_free_metadata.max_staleness_days,
risk_free_rate_observed_max_staleness_days: risk_free_metadata.observed_max_staleness_days,
risk_free_rate_sha256: risk_free_metadata.sha256,
})
}
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
point.unit_nav
} else {
safe_div(point.total_equity, initial_cash, 1.0)
}
}
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 aligned_daily_risk_free_rates(
equity_curve: &[DailyEquityPoint],
contract: Option<&RiskFreeRateContract>,
) -> Result<(Vec<f64>, RiskFreeRateContract), String> {
let Some(contract) = contract else {
return Ok((
vec![0.0; equity_curve.len()],
RiskFreeRateContract {
version: "not-configured".to_string(),
source: "not-configured".to_string(),
tenor: "NONE".to_string(),
periods_per_year: TRADING_DAYS_PER_YEAR,
..RiskFreeRateContract::default()
},
));
};
if contract.version.trim().is_empty()
|| contract.source.trim().is_empty()
|| contract.tenor.trim().is_empty()
|| contract.sha256.len() != 64
{
return Err("risk-free rate contract metadata is incomplete".to_string());
}
if contract.observations.len() != equity_curve.len() {
return Err(format!(
"risk-free rate observation count mismatch: expected={} actual={}",
equity_curve.len(),
contract.observations.len()
));
}
let mut rates = Vec::with_capacity(equity_curve.len());
for (point, observation) in equity_curve.iter().zip(&contract.observations) {
if observation.date != point.date {
return Err(format!(
"risk-free rate date mismatch: expected={} actual={}",
point.date, observation.date
));
}
if observation.source_date > observation.date {
return Err(format!(
"risk-free rate uses future observation: date={} source_date={}",
observation.date, observation.source_date
));
}
let staleness = observation
.date
.signed_duration_since(observation.source_date)
.num_days();
if staleness < 0 || staleness as usize > contract.max_staleness_days {
return Err(format!(
"risk-free rate observation is stale: date={} source_date={} days={}",
observation.date, observation.source_date, staleness
));
}
if !observation.annual_rate.is_finite()
|| observation.annual_rate <= -1.0
|| observation.annual_rate >= 1.0
|| !observation.daily_rate.is_finite()
|| observation.daily_rate <= -1.0
{
return Err(format!(
"risk-free rate observation is invalid: date={}",
observation.date
));
}
let periods_per_year =
if contract.periods_per_year.is_finite() && contract.periods_per_year > 0.0 {
contract.periods_per_year
} else {
TRADING_DAYS_PER_YEAR
};
let expected_daily = (1.0 + observation.annual_rate).powf(1.0 / periods_per_year) - 1.0;
if (expected_daily - observation.daily_rate).abs() > 1e-12 {
return Err(format!(
"risk-free daily conversion mismatch: date={} expected={} actual={}",
observation.date, expected_daily, observation.daily_rate
));
}
rates.push(observation.daily_rate);
}
Ok((rates, contract.clone()))
}
fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -> f64 {
if daily_rates.is_empty() {
return 0.0;
}
let mean_log =
daily_rates.iter().map(|rate| rate.ln_1p()).sum::<f64>() / daily_rates.len() as f64;
(mean_log * periods_per_year).exp_m1()
}
fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 {
if returns.len() < 2 || returns.len() != daily_risk_free_rates.len() {
return 0.0;
}
let adjusted = returns
.iter()
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.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_risk_free_rates: &[f64],
periods_per_year: f64,
) -> f64 {
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
return 0.0;
}
let adjusted = returns
.iter()
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let downside = adjusted
.iter()
.map(|value| value.min(0.0).powi(2))
.sum::<f64>();
let downside_dev = (downside / adjusted.len() as f64).sqrt();
if downside_dev <= f64::EPSILON {
0.0
} else {
mean(&adjusted) / downside_dev * periods_per_year.sqrt()
}
}
fn annualized_downside_risk(
returns: &[f64],
daily_risk_free_rates: &[f64],
periods_per_year: f64,
) -> f64 {
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() {
return 0.0;
}
let downside_mean_square = returns
.iter()
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| (value - risk_free).min(0.0).powi(2))
.sum::<f64>()
/ returns.len() as f64;
downside_mean_square.sqrt() * 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_risk_free_rates: &[f64],
) -> (f64, f64) {
if returns.len() < 2
|| returns.len() != benchmark_returns.len()
|| returns.len() != daily_risk_free_rates.len()
{
return (0.0, 0.0);
}
let strategy_excess = returns
.iter()
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let benchmark_excess = benchmark_returns
.iter()
.zip(daily_risk_free_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let mean_strategy = mean(&strategy_excess);
let mean_benchmark = mean(&benchmark_excess);
let mean_raw_strategy = mean(returns);
let mean_raw_benchmark = mean(benchmark_returns);
let variance_benchmark = variance(benchmark_returns);
if variance_benchmark <= f64::EPSILON {
return (0.0, 0.0);
}
let covariance = returns
.iter()
.zip(benchmark_returns.iter())
.map(|(lhs, rhs)| (lhs - mean_raw_strategy) * (rhs - mean_raw_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 flow_neutral_nav_series(
equity_curve: &[DailyEquityPoint],
account_events: &[AccountEvent],
initial_cash: f64,
) -> Vec<f64> {
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
for event in account_events {
if !(event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount="))
{
continue;
}
*external_flow_by_date.entry(event.date).or_default() +=
event.cash_after - event.cash_before;
}
let mut units = initial_cash;
let mut previous_equity = initial_cash;
let mut navs = Vec::with_capacity(equity_curve.len());
for point in equity_curve {
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
let external_flow = external_flow_by_date
.get(&point.date)
.copied()
.unwrap_or_default();
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
units += external_flow / unit_nav_before_flow;
}
let unit_nav = safe_div(point.total_equity, units, 0.0);
navs.push(unit_nav);
previous_equity = point.total_equity;
}
navs
}
fn external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
account_events
.iter()
.filter(|event| {
event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount=")
})
.map(|event| event.cash_after - event.cash_before)
.sum()
}
fn group_monthly_returns_from_values(
equity_curve: &[DailyEquityPoint],
values: &[f64],
) -> Vec<f64> {
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
let mut previous_value = 1.0;
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
let key = (point.date.year(), point.date.month());
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 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 group_monthly_risk_free_returns(
equity_curve: &[DailyEquityPoint],
daily_risk_free_rates: &[f64],
) -> Vec<f64> {
if equity_curve.len() != daily_risk_free_rates.len() {
return Vec::new();
}
let mut monthly_growth = BTreeMap::<(i32, u32), f64>::new();
for (point, daily_rate) in equity_curve.iter().zip(daily_risk_free_rates) {
let growth = monthly_growth
.entry((point.date.year(), point.date.month()))
.or_insert(1.0);
*growth *= 1.0 + daily_rate;
}
monthly_growth
.into_values()
.map(|growth| growth - 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,
external_cash_flow: 0.0,
unit_nav: total_equity / 100.0,
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, None).unwrap();
let expected = 7595.285 / 5957.717 - 1.0;
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
}
#[test]
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
let curve = vec![
equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 220.0,
market_value: 0.0,
total_equity: 220.0,
external_cash_flow: 100.0,
unit_nav: 1.1,
benchmark_close: 100.0,
benchmark_prev_close: 100.0,
notes: String::new(),
diagnostics: String::new(),
},
];
let events = vec![AccountEvent {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash_before: 100.0,
cash_after: 200.0,
total_equity: 200.0,
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
}];
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0, None).unwrap();
assert!((metrics.total_return - 0.1).abs() < 1e-12);
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
}
#[test]
fn risk_adjusted_metrics_use_daily_pit_rates_and_all_period_downside() {
let curve = vec![
equity_point("2026-01-02", 101.0, 100.0, 100.0),
equity_point("2026-01-05", 98.98, 100.0, 100.0),
equity_point("2026-01-06", 100.4647, 100.0, 100.0),
equity_point("2026-01-07", 99.9623765, 100.0, 100.0),
];
let annual_rates = [0.012, 0.012, 0.013, 0.013];
let observations = curve
.iter()
.zip(annual_rates)
.map(|(point, annual_rate)| RiskFreeRateObservation {
date: point.date,
source_date: point.date,
annual_rate,
daily_rate: (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0,
})
.collect();
let contract = RiskFreeRateContract {
version: "cn-government-bond-3m-pit-daily/v1".to_string(),
source: "test".to_string(),
tenor: "3M".to_string(),
periods_per_year: TRADING_DAYS_PER_YEAR,
max_staleness_days: 15,
observed_max_staleness_days: 0,
sha256: "a".repeat(64),
observations,
};
let metrics =
compute_backtest_metrics(&curve, &[], &[], &[], 100.0, Some(&contract)).unwrap();
let returns = [0.01, -0.02, 0.015, -0.005];
let daily_rates = annual_rates
.map(|annual_rate| (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0);
let adjusted = returns
.iter()
.zip(daily_rates)
.map(|(value, risk_free)| value - risk_free)
.collect::<Vec<_>>();
let expected_sharpe = mean(&adjusted) / std_dev(&adjusted) * TRADING_DAYS_PER_YEAR.sqrt();
let downside = (adjusted
.iter()
.map(|value| value.min(0.0).powi(2))
.sum::<f64>()
/ adjusted.len() as f64)
.sqrt();
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
assert!((metrics.sharpe - expected_sharpe).abs() < 1e-12);
assert!((metrics.sortino - expected_sortino).abs() < 1e-12);
assert!((metrics.downside_risk - downside * TRADING_DAYS_PER_YEAR.sqrt()).abs() < 1e-12);
assert_eq!(metrics.risk_free_rate_source, "test");
assert_eq!(metrics.risk_free_rate_tenor, "3M");
assert_eq!(metrics.risk_free_rate_observation_count, 4);
assert_ne!(metrics.risk_free_rate, 0.022);
}
}