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, } #[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 { 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 = first_point.benchmark_reference_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::>() } 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::>(); 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::>(); let excess_nav_series = equity_nav .iter() .zip(benchmark_nav_series.iter()) .map(|(lhs, rhs)| safe_div(*lhs, *rhs, *lhs)) .collect::>(); 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::>(); 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::::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::>(); 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::() / 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::>(); let weights = latest_holdings .iter() .map(|holding| safe_div(holding.market_value, last_point.total_equity, 0.0)) .collect::>(); 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::(); 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, 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::() / 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::>(); 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::>(); let downside = adjusted .iter() .map(|value| value.min(0.0).powi(2)) .sum::(); 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::() / 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::>(); let benchmark_excess = benchmark_returns .iter() .zip(daily_risk_free_rates) .map(|(value, risk_free)| value - risk_free) .collect::>(); 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::() / (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 { let mut external_flow_by_date = BTreeMap::::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 { 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::>(); 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( equity_curve: &[DailyEquityPoint], initial_value: f64, value_fn: F, ) -> Vec 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::>(); 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 { 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::() / 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::() / (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 { signal_baseline: false, 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 signal_baseline_uses_same_close_for_strategy_and_benchmark() { let mut baseline=equity_point("2026-09-04",100.0,4548.0499,4552.5784); baseline.signal_baseline=true; let curve=vec![baseline,equity_point("2026-09-08",104.0,4558.7371,4575.0245)]; let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap(); assert!((metrics.benchmark_cumulative_return-(4558.7371/4548.0499-1.0)).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 { signal_baseline: false, 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::>(); 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::() / 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); } }