From 7485d4ceb9b865b6f43e56c605db3e1869840285 Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 27 Sep 2026 18:44:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=9E=E6=B5=8B=E6=9C=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E9=A3=8E=E9=99=A9=E6=AF=94=E7=8E=87=E8=A2=AB?= =?UTF-8?q?=E8=A1=A5=E6=88=90=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 14 +-- crates/fidc-core/src/metrics.rs | 165 ++++++++++++++++++++------------ 2 files changed, 110 insertions(+), 69 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index d32b8e7..d5c1943 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -278,13 +278,13 @@ pub struct AnalyzerRiskSummary { pub annual_return: f64, pub benchmark_cumulative_return: f64, pub excess_cumulative_return: f64, - pub alpha: f64, - pub beta: f64, - pub sharpe: f64, - pub sortino: f64, - pub information_ratio: f64, - pub tracking_error: f64, - pub volatility: f64, + pub alpha: Option, + pub beta: Option, + pub sharpe: Option, + pub sortino: Option, + pub information_ratio: Option, + pub tracking_error: Option, + pub volatility: Option, pub max_drawdown: f64, pub max_drawdown_duration_days: usize, pub win_rate: f64, diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index 3bbb39a..a27c92e 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -9,6 +9,7 @@ use crate::portfolio::HoldingSummary; const TRADING_DAYS_PER_YEAR: f64 = 252.0; const MONTHS_PER_YEAR: f64 = 12.0; +pub const RISK_STATISTICS_VERSION: &str = "fidc-risk-statistics/v2"; /// Shared by historical backtests and observed paper/live account returns. /// Undefined ratios remain None; callers must not invent a risk-free rate. @@ -55,9 +56,6 @@ mod risk_adjusted_contract_tests { let downside = (values.iter().map(|r|r.min(0.0).powi(2)).sum::()/3.0).sqrt(); assert!((stats.sharpe.unwrap()-average/deviation*252.0_f64.sqrt()).abs()<1e-12); assert!((stats.sortino.unwrap()-average/downside*252.0_f64.sqrt()).abs()<1e-12); - assert_eq!(stats.sharpe.unwrap(),annualized_sharpe(&returns,&rates,252.0)); - assert_eq!(stats.sortino.unwrap(),annualized_sortino(&returns,&rates,252.0)); - assert_eq!(stats.downside_volatility.unwrap(),annualized_downside_risk(&returns,&rates,252.0)); assert_ne!(stats.sharpe, risk_adjusted_statistics(&returns,&[0.0;3],252.0).unwrap().sharpe); } @@ -105,13 +103,15 @@ pub struct RiskFreeRateContract { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BacktestMetrics { + #[serde(default)] + pub risk_statistics_version: String, pub total_return: f64, pub annual_return: f64, - pub sharpe: f64, + pub sharpe: Option, pub max_drawdown: f64, pub win_rate: f64, - pub alpha: f64, - pub beta: f64, + pub alpha: Option, + pub beta: Option, pub benchmark_cumulative_return: f64, pub benchmark_net_value: f64, pub risk_free_rate: f64, @@ -120,14 +120,14 @@ pub struct BacktestMetrics { 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 sortino: Option, + pub downside_risk: Option, + pub information_ratio: Option, + pub tracking_error: Option, + pub volatility: Option, pub excess_return: f64, - pub excess_sharpe: f64, - pub excess_volatility: f64, + pub excess_sharpe: Option, + pub excess_volatility: Option, pub excess_max_drawdown: f64, pub holding_count: usize, pub average_weight: f64, @@ -146,8 +146,8 @@ pub struct BacktestMetrics { #[serde(default)] pub external_cash_flow_total: f64, pub excess_win_rate: f64, - pub monthly_sharpe: f64, - pub monthly_volatility: f64, + pub monthly_sharpe: Option, + pub monthly_volatility: Option, pub risk_free_rate_contract_version: String, pub risk_free_rate_source: String, pub risk_free_rate_tenor: String, @@ -176,12 +176,14 @@ pub fn compute_backtest_metrics_with_manual( ) -> Result { let Some(first_point) = equity_curve.first() else { return Ok(BacktestMetrics { + risk_statistics_version: RISK_STATISTICS_VERSION.into(), initial_cash, ..BacktestMetrics::default() }); }; let Some(last_point) = equity_curve.last() else { return Ok(BacktestMetrics { + risk_statistics_version: RISK_STATISTICS_VERSION.into(), initial_cash, ..BacktestMetrics::default() }); @@ -204,6 +206,10 @@ pub fn compute_backtest_metrics_with_manual( flow_neutral_nav_series(equity_curve, account_events, initial_cash) }; let mut returns = Vec::with_capacity(portfolio_nav.len()); + // A zero terminal NAV is a real -100% return. A later return starting + // from zero has no denominator and must not become another zero return. + let risk_periods_valid = portfolio_nav.iter().all(|nav|nav.is_finite() && *nav >= 0.0) + && portfolio_nav.windows(2).all(|pair| pair[0] > 0.0); if let Some(first_nav) = portfolio_nav.first().copied() { returns.push(pct_change(1.0, first_nav)); } @@ -247,24 +253,23 @@ pub fn compute_backtest_metrics_with_manual( 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( + let risk_stats = if risk_periods_valid { + risk_adjusted_statistics(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR)? + } else { RiskAdjustedStatistics::default() }; + let sharpe = risk_stats.sharpe; + let sortino = risk_stats.sortino; + let downside_risk = risk_stats.downside_volatility; + let excess_stats = if risk_periods_valid { risk_adjusted_statistics( &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); + )? } else { RiskAdjustedStatistics::default() }; + let information_ratio = excess_stats.sharpe; + let tracking_error = risk_periods_valid.then(||annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR)).flatten(); + let volatility = risk_periods_valid.then(||annualized_std(&returns, TRADING_DAYS_PER_YEAR)).flatten(); + let excess_volatility = tracking_error; + let excess_sharpe = excess_stats.sharpe; + let (alpha, beta) = if risk_periods_valid { alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates) } else { (None,None) }; let equity_nav = portfolio_nav; let benchmark_nav_series = equity_curve @@ -302,12 +307,12 @@ pub fn compute_backtest_metrics_with_manual( .count(), monthly_excess_returns.len(), ); - let monthly_sharpe = annualized_sharpe( + let monthly_sharpe = if risk_periods_valid { risk_adjusted_statistics( &monthly_portfolio_returns, &monthly_risk_free_returns, MONTHS_PER_YEAR, - ); - let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); + )?.sharpe } else { None }; + let monthly_volatility = risk_periods_valid.then(||annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR)).flatten(); let mut turnover_by_date = fills .iter() @@ -365,6 +370,7 @@ pub fn compute_backtest_metrics_with_manual( let total_trade_days = equity_by_date.len(); Ok(BacktestMetrics { + risk_statistics_version: RISK_STATISTICS_VERSION.into(), total_return, annual_return, sharpe, @@ -541,40 +547,21 @@ fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) - (mean_log * periods_per_year).exp_m1() } -fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 { - risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sharpe).unwrap_or(0.0) -} - -fn annualized_sortino( - returns: &[f64], - daily_risk_free_rates: &[f64], - periods_per_year: f64, -) -> f64 { - risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sortino).unwrap_or(0.0) -} - -fn annualized_downside_risk( - returns: &[f64], - daily_risk_free_rates: &[f64], - periods_per_year: f64, -) -> f64 { - risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.downside_volatility).unwrap_or(0.0) -} - -fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 { - std_dev(values) * periods_per_year.sqrt() +fn annualized_std(values: &[f64], periods_per_year: f64) -> Option { + (values.len() > 1).then(|| std_dev(values) * periods_per_year.sqrt()) + .filter(|value| value.is_finite()) } fn alpha_beta( returns: &[f64], benchmark_returns: &[f64], daily_risk_free_rates: &[f64], -) -> (f64, f64) { +) -> (Option, Option) { if returns.len() < 2 || returns.len() != benchmark_returns.len() || returns.len() != daily_risk_free_rates.len() { - return (0.0, 0.0); + return (None, None); } let strategy_excess = returns .iter() @@ -592,7 +579,7 @@ fn alpha_beta( let mean_raw_benchmark = mean(benchmark_returns); let variance_benchmark = variance(benchmark_returns); if variance_benchmark <= f64::EPSILON { - return (0.0, 0.0); + return (None, None); } let covariance = returns .iter() @@ -602,7 +589,7 @@ fn alpha_beta( / (strategy_excess.len() - 1) as f64; let beta = covariance / variance_benchmark; let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR; - (alpha, beta) + (Some(alpha).filter(|value| value.is_finite()), Some(beta).filter(|value| value.is_finite())) } fn drawdown_stats(nav: &[f64]) -> (f64, usize) { @@ -959,12 +946,66 @@ mod tests { / 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!((metrics.sharpe.unwrap() - expected_sharpe).abs() < 1e-12); + assert!((metrics.sortino.unwrap() - expected_sortino).abs() < 1e-12); + assert!((metrics.downside_risk.unwrap() - 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); } + + #[test] + fn undefined_ratios_remain_null_in_backtest_metrics() { + for curve in [vec![], vec![equity_point("2026-01-02", 100.0, 100.0, 100.0)], + vec![equity_point("2026-01-02", 100.0, 100.0, 100.0), + equity_point("2026-01-05", 100.0, 100.0, 100.0)]] { + let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap(); + let json = serde_json::to_value(&metrics).unwrap(); + assert_eq!(json["risk_statistics_version"], RISK_STATISTICS_VERSION); + for field in ["sharpe", "sortino", "alpha", "beta", "information_ratio", "excess_sharpe", "monthly_sharpe"] { + assert!(json[field].is_null(), "{field}: {json}"); + } + if curve.len() < 2 { assert_eq!(metrics.volatility, None); } + else { assert_eq!(metrics.volatility, Some(0.0)); } + assert_eq!(metrics.downside_risk, (!curve.is_empty()).then_some(0.0)); + } + } + + #[test] + fn a_defined_zero_ratio_is_not_missing() { + let stats = risk_adjusted_statistics(&[0.01,-0.01], &[0.0,0.0], 252.0).unwrap(); + assert_eq!(stats.sharpe, Some(0.0)); + assert_eq!(stats.sortino, Some(0.0)); + let (alpha, beta) = alpha_beta(&[0.0,0.0], &[0.01,-0.01], &[0.0,0.0]); + assert_eq!((alpha,beta), (Some(0.0),Some(0.0))); + } + + #[test] + fn gains_without_downside_have_no_sortino_or_monthly_sharpe() { + let curve = vec![equity_point("2026-01-02", 101.0, 101.0, 100.0), + equity_point("2026-01-05", 103.02, 103.02, 101.0)]; + let metrics = compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap(); + assert!(metrics.sharpe.unwrap() > 0.0); + assert_eq!(metrics.sortino,None); + assert_eq!(metrics.downside_risk,Some(0.0)); + assert_eq!(metrics.information_ratio,None); + assert_eq!(metrics.excess_sharpe,None); + assert_eq!(metrics.tracking_error,Some(0.0)); + assert_eq!(metrics.monthly_sharpe,None); + assert_eq!(metrics.monthly_volatility,None); + } + + #[test] + fn no_risk_ratio_is_fabricated_after_nav_has_reached_zero() { + let curve = vec![equity_point("2026-01-02", 0.0, 101.0, 100.0), + equity_point("2026-01-05", 0.0, 102.0, 101.0)]; + let metrics=compute_backtest_metrics(&curve,&[],&[],&[],100.0,None).unwrap(); + assert_eq!(metrics.total_return,-1.0); + for value in [metrics.sharpe,metrics.sortino,metrics.alpha,metrics.beta,metrics.information_ratio,metrics.volatility,metrics.monthly_sharpe] { + assert_eq!(value,None); + } + let loss=compute_backtest_metrics(&curve[..1],&[],&[],&[],100.0,None).unwrap(); + assert!(loss.sortino.unwrap()<0.0); + } }