refactor: share dated risk-adjusted return statistics with trading

This commit is contained in:
boris
2026-09-27 14:52:51 +08:00
parent 98732bbc46
commit 4a349ca3c7
2 changed files with 76 additions and 44 deletions
+2 -1
View File
@@ -83,7 +83,8 @@ pub use futures::{
}; };
pub use instrument::Instrument; pub use instrument::Instrument;
pub use metrics::{ pub use metrics::{
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, compute_backtest_metrics, BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation, RiskAdjustedStatistics,
compute_backtest_metrics, risk_adjusted_statistics,
}; };
pub use platform_expr_strategy::{ pub use platform_expr_strategy::{
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
+74 -43
View File
@@ -10,6 +10,77 @@ use crate::portfolio::HoldingSummary;
const TRADING_DAYS_PER_YEAR: f64 = 252.0; const TRADING_DAYS_PER_YEAR: f64 = 252.0;
const MONTHS_PER_YEAR: f64 = 12.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<f64>,
pub sortino: Option<f64>,
pub downside_volatility: Option<f64>,
}
pub fn risk_adjusted_statistics(
returns: &[f64], rates: &[f64], periods_per_year: f64,
) -> Result<RiskAdjustedStatistics, &'static str> {
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::<f64>() / 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<f64> = 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::<f64>()/3.0;
let deviation = (values.iter().map(|r|(r-average).powi(2)).sum::<f64>()/2.0).sqrt();
let downside = (values.iter().map(|r|r.min(0.0).powi(2)).sum::<f64>()/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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RiskFreeRateObservation { 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 { 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() { risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sharpe).unwrap_or(0.0)
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( fn annualized_sortino(
@@ -493,24 +550,7 @@ fn annualized_sortino(
daily_risk_free_rates: &[f64], daily_risk_free_rates: &[f64],
periods_per_year: f64, periods_per_year: f64,
) -> f64 { ) -> f64 {
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() { risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.sortino).unwrap_or(0.0)
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( fn annualized_downside_risk(
@@ -518,16 +558,7 @@ fn annualized_downside_risk(
daily_risk_free_rates: &[f64], daily_risk_free_rates: &[f64],
periods_per_year: f64, periods_per_year: f64,
) -> f64 { ) -> f64 {
if returns.is_empty() || returns.len() != daily_risk_free_rates.len() { risk_adjusted_statistics(returns,daily_risk_free_rates,periods_per_year).ok().and_then(|stats|stats.downside_volatility).unwrap_or(0.0)
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 { fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {