|
|
@@ -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 {
|
|
|
|