|
|
@@ -9,6 +9,75 @@ 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;
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
#[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_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")]
|
|
|
@@ -34,13 +103,15 @@ pub struct RiskFreeRateContract {
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
|
|
pub struct BacktestMetrics {
|
|
|
|
pub struct BacktestMetrics {
|
|
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
|
|
pub risk_statistics_version: String,
|
|
|
|
pub total_return: f64,
|
|
|
|
pub total_return: f64,
|
|
|
|
pub annual_return: f64,
|
|
|
|
pub annual_return: f64,
|
|
|
|
pub sharpe: f64,
|
|
|
|
pub sharpe: Option<f64>,
|
|
|
|
pub max_drawdown: f64,
|
|
|
|
pub max_drawdown: f64,
|
|
|
|
pub win_rate: f64,
|
|
|
|
pub win_rate: f64,
|
|
|
|
pub alpha: f64,
|
|
|
|
pub alpha: Option<f64>,
|
|
|
|
pub beta: f64,
|
|
|
|
pub beta: Option<f64>,
|
|
|
|
pub benchmark_cumulative_return: f64,
|
|
|
|
pub benchmark_cumulative_return: f64,
|
|
|
|
pub benchmark_net_value: f64,
|
|
|
|
pub benchmark_net_value: f64,
|
|
|
|
pub risk_free_rate: f64,
|
|
|
|
pub risk_free_rate: f64,
|
|
|
@@ -49,14 +120,14 @@ pub struct BacktestMetrics {
|
|
|
|
pub excess_annual_return: f64,
|
|
|
|
pub excess_annual_return: f64,
|
|
|
|
pub max_drawdown_duration_days: usize,
|
|
|
|
pub max_drawdown_duration_days: usize,
|
|
|
|
pub total_trade_days: usize,
|
|
|
|
pub total_trade_days: usize,
|
|
|
|
pub sortino: f64,
|
|
|
|
pub sortino: Option<f64>,
|
|
|
|
pub downside_risk: f64,
|
|
|
|
pub downside_risk: Option<f64>,
|
|
|
|
pub information_ratio: f64,
|
|
|
|
pub information_ratio: Option<f64>,
|
|
|
|
pub tracking_error: f64,
|
|
|
|
pub tracking_error: Option<f64>,
|
|
|
|
pub volatility: f64,
|
|
|
|
pub volatility: Option<f64>,
|
|
|
|
pub excess_return: f64,
|
|
|
|
pub excess_return: f64,
|
|
|
|
pub excess_sharpe: f64,
|
|
|
|
pub excess_sharpe: Option<f64>,
|
|
|
|
pub excess_volatility: f64,
|
|
|
|
pub excess_volatility: Option<f64>,
|
|
|
|
pub excess_max_drawdown: f64,
|
|
|
|
pub excess_max_drawdown: f64,
|
|
|
|
pub holding_count: usize,
|
|
|
|
pub holding_count: usize,
|
|
|
|
pub average_weight: f64,
|
|
|
|
pub average_weight: f64,
|
|
|
@@ -75,8 +146,8 @@ pub struct BacktestMetrics {
|
|
|
|
#[serde(default)]
|
|
|
|
#[serde(default)]
|
|
|
|
pub external_cash_flow_total: f64,
|
|
|
|
pub external_cash_flow_total: f64,
|
|
|
|
pub excess_win_rate: f64,
|
|
|
|
pub excess_win_rate: f64,
|
|
|
|
pub monthly_sharpe: f64,
|
|
|
|
pub monthly_sharpe: Option<f64>,
|
|
|
|
pub monthly_volatility: f64,
|
|
|
|
pub monthly_volatility: Option<f64>,
|
|
|
|
pub risk_free_rate_contract_version: String,
|
|
|
|
pub risk_free_rate_contract_version: String,
|
|
|
|
pub risk_free_rate_source: String,
|
|
|
|
pub risk_free_rate_source: String,
|
|
|
|
pub risk_free_rate_tenor: String,
|
|
|
|
pub risk_free_rate_tenor: String,
|
|
|
@@ -105,12 +176,14 @@ pub fn compute_backtest_metrics_with_manual(
|
|
|
|
) -> Result<BacktestMetrics, String> {
|
|
|
|
) -> Result<BacktestMetrics, String> {
|
|
|
|
let Some(first_point) = equity_curve.first() else {
|
|
|
|
let Some(first_point) = equity_curve.first() else {
|
|
|
|
return Ok(BacktestMetrics {
|
|
|
|
return Ok(BacktestMetrics {
|
|
|
|
|
|
|
|
risk_statistics_version: RISK_STATISTICS_VERSION.into(),
|
|
|
|
initial_cash,
|
|
|
|
initial_cash,
|
|
|
|
..BacktestMetrics::default()
|
|
|
|
..BacktestMetrics::default()
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
};
|
|
|
|
let Some(last_point) = equity_curve.last() else {
|
|
|
|
let Some(last_point) = equity_curve.last() else {
|
|
|
|
return Ok(BacktestMetrics {
|
|
|
|
return Ok(BacktestMetrics {
|
|
|
|
|
|
|
|
risk_statistics_version: RISK_STATISTICS_VERSION.into(),
|
|
|
|
initial_cash,
|
|
|
|
initial_cash,
|
|
|
|
..BacktestMetrics::default()
|
|
|
|
..BacktestMetrics::default()
|
|
|
|
});
|
|
|
|
});
|
|
|
@@ -133,6 +206,10 @@ pub fn compute_backtest_metrics_with_manual(
|
|
|
|
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
|
|
|
|
flow_neutral_nav_series(equity_curve, account_events, initial_cash)
|
|
|
|
};
|
|
|
|
};
|
|
|
|
let mut returns = Vec::with_capacity(portfolio_nav.len());
|
|
|
|
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() {
|
|
|
|
if let Some(first_nav) = portfolio_nav.first().copied() {
|
|
|
|
returns.push(pct_change(1.0, first_nav));
|
|
|
|
returns.push(pct_change(1.0, first_nav));
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -176,24 +253,23 @@ pub fn compute_backtest_metrics_with_manual(
|
|
|
|
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
|
|
|
|
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
|
|
|
|
let risk_free_rate =
|
|
|
|
let risk_free_rate =
|
|
|
|
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
|
|
|
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 risk_stats = if risk_periods_valid {
|
|
|
|
let sortino = annualized_sortino(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
|
|
|
risk_adjusted_statistics(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR)?
|
|
|
|
let downside_risk =
|
|
|
|
} else { RiskAdjustedStatistics::default() };
|
|
|
|
annualized_downside_risk(&returns, &daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
|
|
|
let sharpe = risk_stats.sharpe;
|
|
|
|
let information_ratio = annualized_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,
|
|
|
|
&excess_returns,
|
|
|
|
&zero_risk_free_rates,
|
|
|
|
&zero_risk_free_rates,
|
|
|
|
TRADING_DAYS_PER_YEAR,
|
|
|
|
TRADING_DAYS_PER_YEAR,
|
|
|
|
);
|
|
|
|
)? } else { RiskAdjustedStatistics::default() };
|
|
|
|
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
|
|
|
let information_ratio = excess_stats.sharpe;
|
|
|
|
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
|
|
|
let tracking_error = risk_periods_valid.then(||annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR)).flatten();
|
|
|
|
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
|
|
|
let volatility = risk_periods_valid.then(||annualized_std(&returns, TRADING_DAYS_PER_YEAR)).flatten();
|
|
|
|
let excess_sharpe = annualized_sharpe(
|
|
|
|
let excess_volatility = tracking_error;
|
|
|
|
&excess_returns,
|
|
|
|
let excess_sharpe = excess_stats.sharpe;
|
|
|
|
&zero_risk_free_rates,
|
|
|
|
let (alpha, beta) = if risk_periods_valid { alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates) } else { (None,None) };
|
|
|
|
TRADING_DAYS_PER_YEAR,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, &daily_risk_free_rates);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let equity_nav = portfolio_nav;
|
|
|
|
let equity_nav = portfolio_nav;
|
|
|
|
let benchmark_nav_series = equity_curve
|
|
|
|
let benchmark_nav_series = equity_curve
|
|
|
@@ -231,12 +307,12 @@ pub fn compute_backtest_metrics_with_manual(
|
|
|
|
.count(),
|
|
|
|
.count(),
|
|
|
|
monthly_excess_returns.len(),
|
|
|
|
monthly_excess_returns.len(),
|
|
|
|
);
|
|
|
|
);
|
|
|
|
let monthly_sharpe = annualized_sharpe(
|
|
|
|
let monthly_sharpe = if risk_periods_valid { risk_adjusted_statistics(
|
|
|
|
&monthly_portfolio_returns,
|
|
|
|
&monthly_portfolio_returns,
|
|
|
|
&monthly_risk_free_returns,
|
|
|
|
&monthly_risk_free_returns,
|
|
|
|
MONTHS_PER_YEAR,
|
|
|
|
MONTHS_PER_YEAR,
|
|
|
|
);
|
|
|
|
)?.sharpe } else { None };
|
|
|
|
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
|
|
|
let monthly_volatility = risk_periods_valid.then(||annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR)).flatten();
|
|
|
|
|
|
|
|
|
|
|
|
let mut turnover_by_date = fills
|
|
|
|
let mut turnover_by_date = fills
|
|
|
|
.iter()
|
|
|
|
.iter()
|
|
|
@@ -294,6 +370,7 @@ pub fn compute_backtest_metrics_with_manual(
|
|
|
|
let total_trade_days = equity_by_date.len();
|
|
|
|
let total_trade_days = equity_by_date.len();
|
|
|
|
|
|
|
|
|
|
|
|
Ok(BacktestMetrics {
|
|
|
|
Ok(BacktestMetrics {
|
|
|
|
|
|
|
|
risk_statistics_version: RISK_STATISTICS_VERSION.into(),
|
|
|
|
total_return,
|
|
|
|
total_return,
|
|
|
|
annual_return,
|
|
|
|
annual_return,
|
|
|
|
sharpe,
|
|
|
|
sharpe,
|
|
|
@@ -470,80 +547,21 @@ fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -
|
|
|
|
(mean_log * periods_per_year).exp_m1()
|
|
|
|
(mean_log * periods_per_year).exp_m1()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn annualized_sharpe(returns: &[f64], daily_risk_free_rates: &[f64], periods_per_year: f64) -> f64 {
|
|
|
|
fn annualized_std(values: &[f64], periods_per_year: f64) -> Option<f64> {
|
|
|
|
if returns.len() < 2 || returns.len() != daily_risk_free_rates.len() {
|
|
|
|
(values.len() > 1).then(|| std_dev(values) * periods_per_year.sqrt())
|
|
|
|
return 0.0;
|
|
|
|
.filter(|value| value.is_finite())
|
|
|
|
}
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
|
|
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::<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(
|
|
|
|
|
|
|
|
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::<f64>()
|
|
|
|
|
|
|
|
/ 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(
|
|
|
|
fn alpha_beta(
|
|
|
|
returns: &[f64],
|
|
|
|
returns: &[f64],
|
|
|
|
benchmark_returns: &[f64],
|
|
|
|
benchmark_returns: &[f64],
|
|
|
|
daily_risk_free_rates: &[f64],
|
|
|
|
daily_risk_free_rates: &[f64],
|
|
|
|
) -> (f64, f64) {
|
|
|
|
) -> (Option<f64>, Option<f64>) {
|
|
|
|
if returns.len() < 2
|
|
|
|
if returns.len() < 2
|
|
|
|
|| returns.len() != benchmark_returns.len()
|
|
|
|
|| returns.len() != benchmark_returns.len()
|
|
|
|
|| returns.len() != daily_risk_free_rates.len()
|
|
|
|
|| returns.len() != daily_risk_free_rates.len()
|
|
|
|
{
|
|
|
|
{
|
|
|
|
return (0.0, 0.0);
|
|
|
|
return (None, None);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let strategy_excess = returns
|
|
|
|
let strategy_excess = returns
|
|
|
|
.iter()
|
|
|
|
.iter()
|
|
|
@@ -561,7 +579,7 @@ fn alpha_beta(
|
|
|
|
let mean_raw_benchmark = mean(benchmark_returns);
|
|
|
|
let mean_raw_benchmark = mean(benchmark_returns);
|
|
|
|
let variance_benchmark = variance(benchmark_returns);
|
|
|
|
let variance_benchmark = variance(benchmark_returns);
|
|
|
|
if variance_benchmark <= f64::EPSILON {
|
|
|
|
if variance_benchmark <= f64::EPSILON {
|
|
|
|
return (0.0, 0.0);
|
|
|
|
return (None, None);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let covariance = returns
|
|
|
|
let covariance = returns
|
|
|
|
.iter()
|
|
|
|
.iter()
|
|
|
@@ -571,7 +589,7 @@ fn alpha_beta(
|
|
|
|
/ (strategy_excess.len() - 1) as f64;
|
|
|
|
/ (strategy_excess.len() - 1) as f64;
|
|
|
|
let beta = covariance / variance_benchmark;
|
|
|
|
let beta = covariance / variance_benchmark;
|
|
|
|
let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR;
|
|
|
|
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) {
|
|
|
|
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
|
|
@@ -928,12 +946,66 @@ mod tests {
|
|
|
|
/ adjusted.len() as f64)
|
|
|
|
/ adjusted.len() as f64)
|
|
|
|
.sqrt();
|
|
|
|
.sqrt();
|
|
|
|
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
|
|
|
|
let expected_sortino = mean(&adjusted) / downside * TRADING_DAYS_PER_YEAR.sqrt();
|
|
|
|
assert!((metrics.sharpe - expected_sharpe).abs() < 1e-12);
|
|
|
|
assert!((metrics.sharpe.unwrap() - expected_sharpe).abs() < 1e-12);
|
|
|
|
assert!((metrics.sortino - expected_sortino).abs() < 1e-12);
|
|
|
|
assert!((metrics.sortino.unwrap() - expected_sortino).abs() < 1e-12);
|
|
|
|
assert!((metrics.downside_risk - downside * TRADING_DAYS_PER_YEAR.sqrt()).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_source, "test");
|
|
|
|
assert_eq!(metrics.risk_free_rate_tenor, "3M");
|
|
|
|
assert_eq!(metrics.risk_free_rate_tenor, "3M");
|
|
|
|
assert_eq!(metrics.risk_free_rate_observation_count, 4);
|
|
|
|
assert_eq!(metrics.risk_free_rate_observation_count, 4);
|
|
|
|
assert_ne!(metrics.risk_free_rate, 0.022);
|
|
|
|
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);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|