Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -16,7 +16,7 @@ use crate::futures::{
|
|||||||
FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect,
|
FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect,
|
||||||
FuturesTransactionCostModel,
|
FuturesTransactionCostModel,
|
||||||
};
|
};
|
||||||
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics};
|
||||||
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||||
use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope};
|
use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope};
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
@@ -455,6 +455,7 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||||
execution_quote_request_cache:
|
execution_quote_request_cache:
|
||||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, C, R> BacktestEngine<S, C, R> {
|
impl<S, C, R> BacktestEngine<S, C, R> {
|
||||||
@@ -485,9 +486,15 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
futures_validation_config: FuturesValidationConfig::default(),
|
futures_validation_config: FuturesValidationConfig::default(),
|
||||||
execution_quote_loader: None,
|
execution_quote_loader: None,
|
||||||
execution_quote_request_cache: BTreeSet::new(),
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
|
risk_free_rate_contract: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_risk_free_rate_contract(mut self, contract: RiskFreeRateContract) -> Self {
|
||||||
|
self.risk_free_rate_contract = Some(contract);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn into_data(self) -> DataSet {
|
pub fn into_data(self) -> DataSet {
|
||||||
self.data
|
self.data
|
||||||
}
|
}
|
||||||
@@ -3369,7 +3376,9 @@ where
|
|||||||
&result.daily_holdings,
|
&result.daily_holdings,
|
||||||
&result.account_events,
|
&result.account_events,
|
||||||
self.aggregate_initial_cash(),
|
self.aggregate_initial_cash(),
|
||||||
);
|
self.risk_free_rate_contract.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(BacktestError::Execution)?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ pub use futures::{
|
|||||||
FuturesTradingParameter, FuturesTransactionCostModel,
|
FuturesTradingParameter, FuturesTransactionCostModel,
|
||||||
};
|
};
|
||||||
pub use instrument::Instrument;
|
pub use instrument::Instrument;
|
||||||
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
pub use metrics::{
|
||||||
|
BacktestMetrics, RiskFreeRateContract, RiskFreeRateObservation,
|
||||||
|
compute_backtest_metrics,
|
||||||
|
};
|
||||||
pub use platform_expr_strategy::{
|
pub use platform_expr_strategy::{
|
||||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||||
|
|||||||
+291
-41
@@ -9,7 +9,28 @@ 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;
|
||||||
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RiskFreeRateObservation {
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub source_date: NaiveDate,
|
||||||
|
pub annual_rate: f64,
|
||||||
|
pub daily_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RiskFreeRateContract {
|
||||||
|
pub version: String,
|
||||||
|
pub source: String,
|
||||||
|
pub tenor: String,
|
||||||
|
pub periods_per_year: f64,
|
||||||
|
pub max_staleness_days: usize,
|
||||||
|
pub observed_max_staleness_days: usize,
|
||||||
|
pub sha256: String,
|
||||||
|
pub observations: Vec<RiskFreeRateObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct BacktestMetrics {
|
pub struct BacktestMetrics {
|
||||||
@@ -29,6 +50,7 @@ pub struct BacktestMetrics {
|
|||||||
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: f64,
|
||||||
|
pub downside_risk: f64,
|
||||||
pub information_ratio: f64,
|
pub information_ratio: f64,
|
||||||
pub tracking_error: f64,
|
pub tracking_error: f64,
|
||||||
pub volatility: f64,
|
pub volatility: f64,
|
||||||
@@ -55,6 +77,13 @@ pub struct BacktestMetrics {
|
|||||||
pub excess_win_rate: f64,
|
pub excess_win_rate: f64,
|
||||||
pub monthly_sharpe: f64,
|
pub monthly_sharpe: f64,
|
||||||
pub monthly_volatility: f64,
|
pub monthly_volatility: f64,
|
||||||
|
pub risk_free_rate_contract_version: String,
|
||||||
|
pub risk_free_rate_source: String,
|
||||||
|
pub risk_free_rate_tenor: String,
|
||||||
|
pub risk_free_rate_observation_count: usize,
|
||||||
|
pub risk_free_rate_max_staleness_days: usize,
|
||||||
|
pub risk_free_rate_observed_max_staleness_days: usize,
|
||||||
|
pub risk_free_rate_sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_backtest_metrics(
|
pub fn compute_backtest_metrics(
|
||||||
@@ -63,20 +92,19 @@ pub fn compute_backtest_metrics(
|
|||||||
daily_holdings: &[HoldingSummary],
|
daily_holdings: &[HoldingSummary],
|
||||||
account_events: &[AccountEvent],
|
account_events: &[AccountEvent],
|
||||||
initial_cash: f64,
|
initial_cash: f64,
|
||||||
) -> BacktestMetrics {
|
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||||
|
) -> Result<BacktestMetrics, String> {
|
||||||
let Some(first_point) = equity_curve.first() else {
|
let Some(first_point) = equity_curve.first() else {
|
||||||
return BacktestMetrics {
|
return Ok(BacktestMetrics {
|
||||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
|
||||||
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 BacktestMetrics {
|
return Ok(BacktestMetrics {
|
||||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
|
||||||
initial_cash,
|
initial_cash,
|
||||||
..BacktestMetrics::default()
|
..BacktestMetrics::default()
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let trade_days = equity_curve.len();
|
let trade_days = equity_curve.len();
|
||||||
@@ -122,6 +150,7 @@ pub fn compute_backtest_metrics(
|
|||||||
.zip(benchmark_returns.iter())
|
.zip(benchmark_returns.iter())
|
||||||
.map(|(lhs, rhs)| lhs - rhs)
|
.map(|(lhs, rhs)| lhs - rhs)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let zero_risk_free_rates = vec![0.0; excess_returns.len()];
|
||||||
|
|
||||||
let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON {
|
let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON {
|
||||||
1.0
|
1.0
|
||||||
@@ -140,16 +169,28 @@ pub fn compute_backtest_metrics(
|
|||||||
let annual_return = annualize_return(total_return, trade_days);
|
let annual_return = annualize_return(total_return, trade_days);
|
||||||
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
||||||
|
|
||||||
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
|
let (daily_risk_free_rates, risk_free_metadata) =
|
||||||
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
|
aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?;
|
||||||
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
let risk_free_rate =
|
||||||
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
effective_annual_risk_free_rate(&daily_risk_free_rates, TRADING_DAYS_PER_YEAR);
|
||||||
let information_ratio = annualized_sharpe(&excess_returns, 0.0, 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(
|
||||||
|
&excess_returns,
|
||||||
|
&zero_risk_free_rates,
|
||||||
|
TRADING_DAYS_PER_YEAR,
|
||||||
|
);
|
||||||
let tracking_error = annualized_std(&excess_returns, 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 volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
||||||
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||||
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
let excess_sharpe = annualized_sharpe(
|
||||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
&excess_returns,
|
||||||
|
&zero_risk_free_rates,
|
||||||
|
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
|
||||||
@@ -178,6 +219,8 @@ pub fn compute_backtest_metrics(
|
|||||||
.zip(monthly_benchmark_returns.iter())
|
.zip(monthly_benchmark_returns.iter())
|
||||||
.map(|(lhs, rhs)| lhs - rhs)
|
.map(|(lhs, rhs)| lhs - rhs)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let monthly_risk_free_returns =
|
||||||
|
group_monthly_risk_free_returns(equity_curve, &daily_risk_free_rates);
|
||||||
let monthly_excess_win_rate = ratio(
|
let monthly_excess_win_rate = ratio(
|
||||||
monthly_excess_returns
|
monthly_excess_returns
|
||||||
.iter()
|
.iter()
|
||||||
@@ -187,7 +230,7 @@ pub fn compute_backtest_metrics(
|
|||||||
);
|
);
|
||||||
let monthly_sharpe = annualized_sharpe(
|
let monthly_sharpe = annualized_sharpe(
|
||||||
&monthly_portfolio_returns,
|
&monthly_portfolio_returns,
|
||||||
risk_free_rate / MONTHS_PER_YEAR,
|
&monthly_risk_free_returns,
|
||||||
MONTHS_PER_YEAR,
|
MONTHS_PER_YEAR,
|
||||||
);
|
);
|
||||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||||
@@ -239,7 +282,7 @@ pub fn compute_backtest_metrics(
|
|||||||
|
|
||||||
let total_trade_days = equity_by_date.len();
|
let total_trade_days = equity_by_date.len();
|
||||||
|
|
||||||
BacktestMetrics {
|
Ok(BacktestMetrics {
|
||||||
total_return,
|
total_return,
|
||||||
annual_return,
|
annual_return,
|
||||||
sharpe,
|
sharpe,
|
||||||
@@ -256,6 +299,7 @@ pub fn compute_backtest_metrics(
|
|||||||
max_drawdown_duration_days,
|
max_drawdown_duration_days,
|
||||||
total_trade_days,
|
total_trade_days,
|
||||||
sortino,
|
sortino,
|
||||||
|
downside_risk,
|
||||||
information_ratio,
|
information_ratio,
|
||||||
tracking_error,
|
tracking_error,
|
||||||
volatility,
|
volatility,
|
||||||
@@ -285,7 +329,14 @@ pub fn compute_backtest_metrics(
|
|||||||
excess_win_rate,
|
excess_win_rate,
|
||||||
monthly_sharpe,
|
monthly_sharpe,
|
||||||
monthly_volatility,
|
monthly_volatility,
|
||||||
}
|
risk_free_rate_contract_version: risk_free_metadata.version,
|
||||||
|
risk_free_rate_source: risk_free_metadata.source,
|
||||||
|
risk_free_rate_tenor: risk_free_metadata.tenor,
|
||||||
|
risk_free_rate_observation_count: daily_risk_free_rates.len(),
|
||||||
|
risk_free_rate_max_staleness_days: risk_free_metadata.max_staleness_days,
|
||||||
|
risk_free_rate_observed_max_staleness_days: risk_free_metadata.observed_max_staleness_days,
|
||||||
|
risk_free_rate_sha256: risk_free_metadata.sha256,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
|
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
|
||||||
@@ -316,13 +367,106 @@ fn annualize_return(total_return: f64, periods: usize) -> f64 {
|
|||||||
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
fn aligned_daily_risk_free_rates(
|
||||||
if returns.len() < 2 {
|
equity_curve: &[DailyEquityPoint],
|
||||||
|
contract: Option<&RiskFreeRateContract>,
|
||||||
|
) -> Result<(Vec<f64>, RiskFreeRateContract), String> {
|
||||||
|
let Some(contract) = contract else {
|
||||||
|
return Ok((
|
||||||
|
vec![0.0; equity_curve.len()],
|
||||||
|
RiskFreeRateContract {
|
||||||
|
version: "not-configured".to_string(),
|
||||||
|
source: "not-configured".to_string(),
|
||||||
|
tenor: "NONE".to_string(),
|
||||||
|
periods_per_year: TRADING_DAYS_PER_YEAR,
|
||||||
|
..RiskFreeRateContract::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if contract.version.trim().is_empty()
|
||||||
|
|| contract.source.trim().is_empty()
|
||||||
|
|| contract.tenor.trim().is_empty()
|
||||||
|
|| contract.sha256.len() != 64
|
||||||
|
{
|
||||||
|
return Err("risk-free rate contract metadata is incomplete".to_string());
|
||||||
|
}
|
||||||
|
if contract.observations.len() != equity_curve.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"risk-free rate observation count mismatch: expected={} actual={}",
|
||||||
|
equity_curve.len(),
|
||||||
|
contract.observations.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut rates = Vec::with_capacity(equity_curve.len());
|
||||||
|
for (point, observation) in equity_curve.iter().zip(&contract.observations) {
|
||||||
|
if observation.date != point.date {
|
||||||
|
return Err(format!(
|
||||||
|
"risk-free rate date mismatch: expected={} actual={}",
|
||||||
|
point.date, observation.date
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if observation.source_date > observation.date {
|
||||||
|
return Err(format!(
|
||||||
|
"risk-free rate uses future observation: date={} source_date={}",
|
||||||
|
observation.date, observation.source_date
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let staleness = observation
|
||||||
|
.date
|
||||||
|
.signed_duration_since(observation.source_date)
|
||||||
|
.num_days();
|
||||||
|
if staleness < 0 || staleness as usize > contract.max_staleness_days {
|
||||||
|
return Err(format!(
|
||||||
|
"risk-free rate observation is stale: date={} source_date={} days={}",
|
||||||
|
observation.date, observation.source_date, staleness
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !observation.annual_rate.is_finite()
|
||||||
|
|| observation.annual_rate <= -1.0
|
||||||
|
|| observation.annual_rate >= 1.0
|
||||||
|
|| !observation.daily_rate.is_finite()
|
||||||
|
|| observation.daily_rate <= -1.0
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"risk-free rate observation is invalid: date={}",
|
||||||
|
observation.date
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let periods_per_year =
|
||||||
|
if contract.periods_per_year.is_finite() && contract.periods_per_year > 0.0 {
|
||||||
|
contract.periods_per_year
|
||||||
|
} else {
|
||||||
|
TRADING_DAYS_PER_YEAR
|
||||||
|
};
|
||||||
|
let expected_daily = (1.0 + observation.annual_rate).powf(1.0 / periods_per_year) - 1.0;
|
||||||
|
if (expected_daily - observation.daily_rate).abs() > 1e-12 {
|
||||||
|
return Err(format!(
|
||||||
|
"risk-free daily conversion mismatch: date={} expected={} actual={}",
|
||||||
|
observation.date, expected_daily, observation.daily_rate
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rates.push(observation.daily_rate);
|
||||||
|
}
|
||||||
|
Ok((rates, contract.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_annual_risk_free_rate(daily_rates: &[f64], periods_per_year: f64) -> f64 {
|
||||||
|
if daily_rates.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mean_log =
|
||||||
|
daily_rates.iter().map(|rate| rate.ln_1p()).sum::<f64>() / daily_rates.len() as f64;
|
||||||
|
(mean_log * periods_per_year).exp_m1()
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let adjusted = returns
|
let adjusted = returns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| value - daily_rf)
|
.zip(daily_risk_free_rates)
|
||||||
|
.map(|(value, risk_free)| value - risk_free)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mean_ret = mean(&adjusted);
|
let mean_ret = mean(&adjusted);
|
||||||
let std = std_dev(&adjusted);
|
let std = std_dev(&adjusted);
|
||||||
@@ -333,23 +477,24 @@ fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
fn annualized_sortino(
|
||||||
if returns.is_empty() {
|
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;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let adjusted = returns
|
let adjusted = returns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| value - daily_rf)
|
.zip(daily_risk_free_rates)
|
||||||
|
.map(|(value, risk_free)| value - risk_free)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let downside = adjusted
|
let downside = adjusted
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|value| **value < 0.0)
|
.map(|value| value.min(0.0).powi(2))
|
||||||
.map(|value| value.powi(2))
|
.sum::<f64>();
|
||||||
.collect::<Vec<_>>();
|
let downside_dev = (downside / adjusted.len() as f64).sqrt();
|
||||||
if downside.is_empty() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
|
|
||||||
if downside_dev <= f64::EPSILON {
|
if downside_dev <= f64::EPSILON {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
@@ -357,32 +502,60 @@ fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
||||||
std_dev(values) * periods_per_year.sqrt()
|
std_dev(values) * periods_per_year.sqrt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
|
fn alpha_beta(
|
||||||
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
returns: &[f64],
|
||||||
|
benchmark_returns: &[f64],
|
||||||
|
daily_risk_free_rates: &[f64],
|
||||||
|
) -> (f64, f64) {
|
||||||
|
if returns.len() < 2
|
||||||
|
|| returns.len() != benchmark_returns.len()
|
||||||
|
|| returns.len() != daily_risk_free_rates.len()
|
||||||
|
{
|
||||||
return (0.0, 0.0);
|
return (0.0, 0.0);
|
||||||
}
|
}
|
||||||
let strategy_excess = returns
|
let strategy_excess = returns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| value - daily_rf)
|
.zip(daily_risk_free_rates)
|
||||||
|
.map(|(value, risk_free)| value - risk_free)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let benchmark_excess = benchmark_returns
|
let benchmark_excess = benchmark_returns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| value - daily_rf)
|
.zip(daily_risk_free_rates)
|
||||||
|
.map(|(value, risk_free)| value - risk_free)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mean_strategy = mean(&strategy_excess);
|
let mean_strategy = mean(&strategy_excess);
|
||||||
let mean_benchmark = mean(&benchmark_excess);
|
let mean_benchmark = mean(&benchmark_excess);
|
||||||
let variance_benchmark = variance(&benchmark_excess);
|
let mean_raw_strategy = mean(returns);
|
||||||
|
let mean_raw_benchmark = mean(benchmark_returns);
|
||||||
|
let variance_benchmark = variance(benchmark_returns);
|
||||||
if variance_benchmark <= f64::EPSILON {
|
if variance_benchmark <= f64::EPSILON {
|
||||||
return (0.0, 0.0);
|
return (0.0, 0.0);
|
||||||
}
|
}
|
||||||
let covariance = strategy_excess
|
let covariance = returns
|
||||||
.iter()
|
.iter()
|
||||||
.zip(benchmark_excess.iter())
|
.zip(benchmark_returns.iter())
|
||||||
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
|
.map(|(lhs, rhs)| (lhs - mean_raw_strategy) * (rhs - mean_raw_benchmark))
|
||||||
.sum::<f64>()
|
.sum::<f64>()
|
||||||
/ (strategy_excess.len() - 1) as f64;
|
/ (strategy_excess.len() - 1) as f64;
|
||||||
let beta = covariance / variance_benchmark;
|
let beta = covariance / variance_benchmark;
|
||||||
@@ -522,6 +695,26 @@ where
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn group_monthly_risk_free_returns(
|
||||||
|
equity_curve: &[DailyEquityPoint],
|
||||||
|
daily_risk_free_rates: &[f64],
|
||||||
|
) -> Vec<f64> {
|
||||||
|
if equity_curve.len() != daily_risk_free_rates.len() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut monthly_growth = BTreeMap::<(i32, u32), f64>::new();
|
||||||
|
for (point, daily_rate) in equity_curve.iter().zip(daily_risk_free_rates) {
|
||||||
|
let growth = monthly_growth
|
||||||
|
.entry((point.date.year(), point.date.month()))
|
||||||
|
.or_insert(1.0);
|
||||||
|
*growth *= 1.0 + daily_rate;
|
||||||
|
}
|
||||||
|
monthly_growth
|
||||||
|
.into_values()
|
||||||
|
.map(|growth| growth - 1.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn mean(values: &[f64]) -> f64 {
|
fn mean(values: &[f64]) -> f64 {
|
||||||
if values.is_empty() {
|
if values.is_empty() {
|
||||||
0.0
|
0.0
|
||||||
@@ -606,7 +799,7 @@ mod tests {
|
|||||||
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
equity_point("2025-01-02", 100.0, 5797.089, 5957.717),
|
||||||
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
equity_point("2025-12-31", 120.0, 7595.285, 7597.299),
|
||||||
];
|
];
|
||||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0);
|
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||||
let expected = 7595.285 / 5957.717 - 1.0;
|
let expected = 7595.285 / 5957.717 - 1.0;
|
||||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
@@ -635,9 +828,66 @@ mod tests {
|
|||||||
total_equity: 200.0,
|
total_equity: 200.0,
|
||||||
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
|
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
|
||||||
}];
|
}];
|
||||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
|
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0, None).unwrap();
|
||||||
assert!((metrics.total_return - 0.1).abs() < 1e-12);
|
assert!((metrics.total_return - 0.1).abs() < 1e-12);
|
||||||
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
|
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
|
||||||
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn risk_adjusted_metrics_use_daily_pit_rates_and_all_period_downside() {
|
||||||
|
let curve = vec![
|
||||||
|
equity_point("2026-01-02", 101.0, 100.0, 100.0),
|
||||||
|
equity_point("2026-01-05", 98.98, 100.0, 100.0),
|
||||||
|
equity_point("2026-01-06", 100.4647, 100.0, 100.0),
|
||||||
|
equity_point("2026-01-07", 99.9623765, 100.0, 100.0),
|
||||||
|
];
|
||||||
|
let annual_rates = [0.012, 0.012, 0.013, 0.013];
|
||||||
|
let observations = curve
|
||||||
|
.iter()
|
||||||
|
.zip(annual_rates)
|
||||||
|
.map(|(point, annual_rate)| RiskFreeRateObservation {
|
||||||
|
date: point.date,
|
||||||
|
source_date: point.date,
|
||||||
|
annual_rate,
|
||||||
|
daily_rate: (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let contract = RiskFreeRateContract {
|
||||||
|
version: "cn-government-bond-3m-pit-daily/v1".to_string(),
|
||||||
|
source: "test".to_string(),
|
||||||
|
tenor: "3M".to_string(),
|
||||||
|
periods_per_year: TRADING_DAYS_PER_YEAR,
|
||||||
|
max_staleness_days: 15,
|
||||||
|
observed_max_staleness_days: 0,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
let metrics =
|
||||||
|
compute_backtest_metrics(&curve, &[], &[], &[], 100.0, Some(&contract)).unwrap();
|
||||||
|
|
||||||
|
let returns = [0.01, -0.02, 0.015, -0.005];
|
||||||
|
let daily_rates = annual_rates
|
||||||
|
.map(|annual_rate| (1.0 + annual_rate).powf(1.0 / TRADING_DAYS_PER_YEAR) - 1.0);
|
||||||
|
let adjusted = returns
|
||||||
|
.iter()
|
||||||
|
.zip(daily_rates)
|
||||||
|
.map(|(value, risk_free)| value - risk_free)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let expected_sharpe = mean(&adjusted) / std_dev(&adjusted) * TRADING_DAYS_PER_YEAR.sqrt();
|
||||||
|
let downside = (adjusted
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.min(0.0).powi(2))
|
||||||
|
.sum::<f64>()
|
||||||
|
/ 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_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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user