diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index dc25d1c..88bf755 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -16,7 +16,7 @@ use crate::futures::{ FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect, FuturesTransactionCostModel, }; -use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; +use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope}; use crate::rules::EquityRuleHooks; @@ -455,6 +455,7 @@ pub struct BacktestEngine { execution_quote_loader: Option, execution_quote_request_cache: BTreeSet<(NaiveDate, String, Option, Option)>, + risk_free_rate_contract: Option, } impl BacktestEngine { @@ -485,9 +486,15 @@ impl BacktestEngine { futures_validation_config: FuturesValidationConfig::default(), execution_quote_loader: None, 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 { self.data } @@ -3374,7 +3381,9 @@ where &result.daily_holdings, &result.account_events, self.aggregate_initial_cash(), - ); + self.risk_free_rate_contract.as_ref(), + ) + .map_err(BacktestError::Execution)?; Ok(result) } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index c2241db..9092467 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -56,7 +56,10 @@ pub use futures::{ FuturesTradingParameter, FuturesTransactionCostModel, }; 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::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index 678c9c7..ba986a2 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -9,7 +9,28 @@ use crate::portfolio::HoldingSummary; const TRADING_DAYS_PER_YEAR: f64 = 252.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, +} #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BacktestMetrics { @@ -55,6 +76,13 @@ pub struct BacktestMetrics { pub excess_win_rate: f64, pub monthly_sharpe: 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( @@ -63,20 +91,19 @@ pub fn compute_backtest_metrics( daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64, -) -> BacktestMetrics { + risk_free_contract: Option<&RiskFreeRateContract>, +) -> Result { let Some(first_point) = equity_curve.first() else { - return BacktestMetrics { - risk_free_rate: DEFAULT_RISK_FREE_RATE, + return Ok(BacktestMetrics { initial_cash, ..BacktestMetrics::default() - }; + }); }; let Some(last_point) = equity_curve.last() else { - return BacktestMetrics { - risk_free_rate: DEFAULT_RISK_FREE_RATE, + return Ok(BacktestMetrics { initial_cash, ..BacktestMetrics::default() - }; + }); }; let trade_days = equity_curve.len(); @@ -122,6 +149,7 @@ pub fn compute_backtest_metrics( .zip(benchmark_returns.iter()) .map(|(lhs, rhs)| lhs - rhs) .collect::>(); + let zero_risk_free_rates = vec![0.0; excess_returns.len()]; let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON { 1.0 @@ -140,16 +168,26 @@ pub fn compute_backtest_metrics( let annual_return = annualize_return(total_return, trade_days); let excess_annual_return = annualize_return(excess_cumulative_return, trade_days); - let risk_free_rate = DEFAULT_RISK_FREE_RATE; - let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR; - let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR); - let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR); - let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR); + let (daily_risk_free_rates, risk_free_metadata) = + aligned_daily_risk_free_rates(equity_curve, risk_free_contract)?; + let risk_free_rate = + 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 sortino = annualized_sortino(&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 volatility = annualized_std(&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 (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf); + let excess_sharpe = annualized_sharpe( + &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 benchmark_nav_series = equity_curve @@ -178,6 +216,8 @@ pub fn compute_backtest_metrics( .zip(monthly_benchmark_returns.iter()) .map(|(lhs, rhs)| lhs - rhs) .collect::>(); + let monthly_risk_free_returns = + group_monthly_risk_free_returns(equity_curve, &daily_risk_free_rates); let monthly_excess_win_rate = ratio( monthly_excess_returns .iter() @@ -187,7 +227,7 @@ pub fn compute_backtest_metrics( ); let monthly_sharpe = annualized_sharpe( &monthly_portfolio_returns, - risk_free_rate / MONTHS_PER_YEAR, + &monthly_risk_free_returns, MONTHS_PER_YEAR, ); let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); @@ -239,7 +279,7 @@ pub fn compute_backtest_metrics( let total_trade_days = equity_by_date.len(); - BacktestMetrics { + Ok(BacktestMetrics { total_return, annual_return, sharpe, @@ -285,7 +325,14 @@ pub fn compute_backtest_metrics( excess_win_rate, monthly_sharpe, 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 { @@ -316,13 +363,106 @@ fn annualize_return(total_return: f64, periods: usize) -> f64 { base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0 } -fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 { - if returns.len() < 2 { +fn aligned_daily_risk_free_rates( + equity_curve: &[DailyEquityPoint], + contract: Option<&RiskFreeRateContract>, +) -> Result<(Vec, 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::() / 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; } let adjusted = returns .iter() - .map(|value| value - daily_rf) + .zip(daily_risk_free_rates) + .map(|(value, risk_free)| value - risk_free) .collect::>(); let mean_ret = mean(&adjusted); let std = std_dev(&adjusted); @@ -333,23 +473,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 { - if returns.is_empty() { +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() - .map(|value| value - daily_rf) + .zip(daily_risk_free_rates) + .map(|(value, risk_free)| value - risk_free) .collect::>(); let downside = adjusted .iter() - .filter(|value| **value < 0.0) - .map(|value| value.powi(2)) - .collect::>(); - if downside.is_empty() { - return 0.0; - } - let downside_dev = (downside.iter().sum::() / downside.len() as f64).sqrt(); + .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 { @@ -361,28 +502,39 @@ fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 { std_dev(values) * periods_per_year.sqrt() } -fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) { - if returns.len() < 2 || returns.len() != benchmark_returns.len() { +fn alpha_beta( + 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); } let strategy_excess = returns .iter() - .map(|value| value - daily_rf) + .zip(daily_risk_free_rates) + .map(|(value, risk_free)| value - risk_free) .collect::>(); let benchmark_excess = benchmark_returns .iter() - .map(|value| value - daily_rf) + .zip(daily_risk_free_rates) + .map(|(value, risk_free)| value - risk_free) .collect::>(); let mean_strategy = mean(&strategy_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 { return (0.0, 0.0); } - let covariance = strategy_excess + let covariance = returns .iter() - .zip(benchmark_excess.iter()) - .map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark)) + .zip(benchmark_returns.iter()) + .map(|(lhs, rhs)| (lhs - mean_raw_strategy) * (rhs - mean_raw_benchmark)) .sum::() / (strategy_excess.len() - 1) as f64; let beta = covariance / variance_benchmark; @@ -522,6 +674,26 @@ where .collect() } +fn group_monthly_risk_free_returns( + equity_curve: &[DailyEquityPoint], + daily_risk_free_rates: &[f64], +) -> Vec { + 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 { if values.is_empty() { 0.0 @@ -606,7 +778,7 @@ mod tests { equity_point("2025-01-02", 100.0, 5797.089, 5957.717), 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; assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12); } @@ -635,9 +807,65 @@ mod tests { total_equity: 200.0, 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.unit_nav - 1.1).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::>(); + 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::() + / 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_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); + } }