From 4a349ca3c7bd9bfea00eecbb248ab5408472a617 Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 27 Sep 2026 14:52:51 +0800 Subject: [PATCH] refactor: share dated risk-adjusted return statistics with trading --- crates/fidc-core/src/lib.rs | 3 +- crates/fidc-core/src/metrics.rs | 117 ++++++++++++++++++++------------ 2 files changed, 76 insertions(+), 44 deletions(-) diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 5dc199b..c2830e7 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -83,7 +83,8 @@ pub use futures::{ }; pub use instrument::Instrument; pub use metrics::{ - BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics, + BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, RiskAdjustedStatistics, + compute_backtest_metrics, risk_adjusted_statistics, }; pub use platform_expr_strategy::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index 4f8f5f3..3bbb39a 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -10,6 +10,77 @@ use crate::portfolio::HoldingSummary; const TRADING_DAYS_PER_YEAR: f64 = 252.0; const MONTHS_PER_YEAR: f64 = 12.0; +/// Shared by historical backtests and observed paper/live account returns. +/// Undefined ratios remain None; callers must not invent a risk-free rate. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct RiskAdjustedStatistics { + pub sharpe: Option, + pub sortino: Option, + pub downside_volatility: Option, +} + +pub fn risk_adjusted_statistics( + returns: &[f64], rates: &[f64], periods_per_year: f64, +) -> Result { + if returns.len() != rates.len() || !periods_per_year.is_finite() || periods_per_year <= 0.0 + || returns.iter().chain(rates).any(|value| !value.is_finite()) { + return Err("risk-adjusted statistics require finite aligned returns and rates"); + } + if returns.is_empty() { return Ok(RiskAdjustedStatistics::default()); } + let adjusted: Vec<_> = returns.iter().zip(rates).map(|(value, rate)| value-rate).collect(); + if adjusted.iter().any(|value| !value.is_finite()) { return Err("risk-adjusted return overflow"); } + let mean_return = mean(&adjusted); + let deviation = std_dev(&adjusted); + let downside = (adjusted.iter().map(|value| value.min(0.0).powi(2)).sum::() / adjusted.len() as f64).sqrt(); + let annual = periods_per_year.sqrt(); + Ok(RiskAdjustedStatistics { + sharpe: (adjusted.len() > 1 && deviation > f64::EPSILON).then_some(mean_return/deviation*annual).filter(|value|value.is_finite()), + sortino: (downside > f64::EPSILON).then_some(mean_return/downside*annual).filter(|value|value.is_finite()), + downside_volatility: Some(downside*annual).filter(|value|value.is_finite()), + }) +} + +#[cfg(test)] +mod risk_adjusted_contract_tests { + use super::*; + + #[test] + fn changing_daily_rates_adjusts_each_return_before_variance_and_downside() { + let returns = [0.02, -0.01, 0.005]; + let rates = [0.0001, 0.0002, 0.0003]; + let values: Vec = returns.iter().zip(rates).map(|(r,f)| r-f).collect(); + let stats = risk_adjusted_statistics(&returns,&rates,252.0).unwrap(); + let average = values.iter().sum::()/3.0; + let deviation = (values.iter().map(|r|(r-average).powi(2)).sum::()/2.0).sqrt(); + 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); + } + + #[test] + fn incomplete_or_invalid_rates_are_not_zero_rate_observations() { + for rates in [vec![],vec![0.0],vec![0.0,f64::NAN],vec![0.0,f64::INFINITY]] { + assert!(risk_adjusted_statistics(&[0.01,-0.01],&rates,252.0).is_err()); + } + assert!(risk_adjusted_statistics(&[f64::NAN],&[0.0],252.0).is_err()); + assert!(risk_adjusted_statistics(&[0.0],&[0.0],0.0).is_err()); + } + + #[test] + fn zero_deviation_and_single_sample_ratios_remain_undefined() { + let flat=risk_adjusted_statistics(&[0.001,0.001],&[0.001,0.001],252.0).unwrap(); + assert!(flat.sharpe.is_none() && flat.sortino.is_none()); + assert_eq!(flat.downside_volatility,Some(0.0)); + let one=risk_adjusted_statistics(&[-0.01],&[0.001],252.0).unwrap(); + assert_eq!(one.sharpe,None); + assert!((one.sortino.unwrap()+252.0_f64.sqrt()).abs()<1e-12); + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RiskFreeRateObservation { @@ -471,21 +542,7 @@ fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) - } 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() - } + risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sharpe).unwrap_or(0.0) } fn annualized_sortino( @@ -493,24 +550,7 @@ fn annualized_sortino( 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() - } + 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( @@ -518,16 +558,7 @@ fn annualized_downside_risk( 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() + 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 {