use std::collections::HashMap;
use crate::backtesting::result::{BenchmarkMetrics, EquityPoint};
use crate::models::chart::Candle;
use super::BacktestEngine;
impl BacktestEngine {
pub(super) fn sync_terminal_equity_point(
equity_curve: &mut Vec<EquityPoint>,
timestamp: i64,
equity: f64,
) {
if let Some(last) = equity_curve.last_mut()
&& last.timestamp == timestamp
{
last.equity = equity;
} else {
equity_curve.push(EquityPoint {
timestamp,
equity,
drawdown_pct: 0.0,
});
}
let peak = equity_curve
.iter()
.map(|point| point.equity)
.fold(f64::NEG_INFINITY, f64::max);
let drawdown = if peak.is_finite() && peak > 0.0 {
(peak - equity) / peak
} else {
0.0
};
if let Some(last) = equity_curve.last_mut() {
last.drawdown_pct = drawdown;
}
}
}
pub(super) fn compute_benchmark_metrics(
benchmark_symbol: &str,
symbol_candles: &[Candle],
benchmark_candles: &[Candle],
equity_curve: &[EquityPoint],
risk_free_rate: f64,
bars_per_year: f64,
) -> BenchmarkMetrics {
let benchmark_return_pct = buy_and_hold_return(benchmark_candles);
let buy_and_hold_return_pct = buy_and_hold_return(symbol_candles);
if equity_curve.len() < 2 || benchmark_candles.len() < 2 {
return BenchmarkMetrics {
symbol: benchmark_symbol.to_string(),
benchmark_return_pct,
buy_and_hold_return_pct,
alpha: 0.0,
beta: 0.0,
information_ratio: 0.0,
tracking_error: 0.0,
};
}
let strategy_returns_by_ts: Vec<(i64, f64)> = equity_curve
.windows(2)
.map(|w| {
let prev = w[0].equity;
let ret = if prev > 0.0 {
(w[1].equity - prev) / prev
} else {
0.0
};
(w[1].timestamp, ret)
})
.collect();
let bench_returns_by_ts: HashMap<i64, f64> = benchmark_candles
.windows(2)
.map(|w| {
let prev = w[0].close;
let ret = if prev > 0.0 {
(w[1].close - prev) / prev
} else {
0.0
};
(w[1].timestamp, ret)
})
.collect();
let mut aligned_strategy = Vec::new();
let mut aligned_benchmark = Vec::new();
for (ts, s_ret) in strategy_returns_by_ts {
if let Some(b_ret) = bench_returns_by_ts.get(&ts) {
aligned_strategy.push(s_ret);
aligned_benchmark.push(*b_ret);
}
}
if aligned_strategy.len() < 2 {
return BenchmarkMetrics {
symbol: benchmark_symbol.to_string(),
benchmark_return_pct,
buy_and_hold_return_pct,
alpha: 0.0,
beta: 0.0,
information_ratio: 0.0,
tracking_error: 0.0,
};
}
let beta = compute_beta(&aligned_strategy, &aligned_benchmark);
let strategy_ann = annualized_return_from_periodic(&aligned_strategy, bars_per_year);
let bench_ann = annualized_return_from_periodic(&aligned_benchmark, bars_per_year);
let rf_ann = risk_free_rate * 100.0;
let alpha = strategy_ann - rf_ann - beta * (bench_ann - rf_ann);
let ir = crate::perf_metrics::information_ratio(
&aligned_strategy,
&aligned_benchmark,
bars_per_year,
)
.unwrap_or(0.0);
let te =
crate::perf_metrics::tracking_error(&aligned_strategy, &aligned_benchmark, bars_per_year)
.unwrap_or(0.0);
BenchmarkMetrics {
symbol: benchmark_symbol.to_string(),
benchmark_return_pct,
buy_and_hold_return_pct,
alpha,
beta,
information_ratio: ir,
tracking_error: te,
}
}
fn buy_and_hold_return(candles: &[Candle]) -> f64 {
match (candles.first(), candles.last()) {
(Some(first), Some(last)) if first.close > 0.0 => {
((last.close / first.close) - 1.0) * 100.0
}
_ => 0.0,
}
}
fn annualized_return_from_periodic(periodic_returns: &[f64], bars_per_year: f64) -> f64 {
let years = periodic_returns.len() as f64 / bars_per_year;
if years > 0.0 {
let growth = periodic_returns
.iter()
.fold(1.0_f64, |acc, r| acc * (1.0 + *r));
if growth <= 0.0 {
-100.0
} else {
(growth.powf(1.0 / years) - 1.0) * 100.0
}
} else {
0.0
}
}
fn compute_beta(strategy_returns: &[f64], benchmark_returns: &[f64]) -> f64 {
let n = strategy_returns.len();
if n < 2 {
return 0.0;
}
let s_mean = strategy_returns.iter().sum::<f64>() / n as f64;
let b_mean = benchmark_returns.iter().sum::<f64>() / n as f64;
let cov: f64 = strategy_returns
.iter()
.zip(benchmark_returns.iter())
.map(|(s, b)| (s - s_mean) * (b - b_mean))
.sum::<f64>()
/ (n - 1) as f64;
let b_var: f64 = benchmark_returns
.iter()
.map(|b| (b - b_mean).powi(2))
.sum::<f64>()
/ (n - 1) as f64;
if b_var > 0.0 { cov / b_var } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backtesting::config::BacktestConfig;
use crate::backtesting::engine::fixtures::*;
use crate::backtesting::strategy::SmaCrossover;
#[test]
fn test_capm_alpha_with_risk_free_rate() {
let prices: Vec<f64> = (0..60).map(|i| 100.0 + i as f64).collect();
let candles = make_candles(&prices);
let config_no_rf = BacktestConfig::builder()
.commission_pct(0.0)
.slippage_pct(0.0)
.risk_free_rate(0.0)
.build()
.unwrap();
let config_with_rf = BacktestConfig::builder()
.commission_pct(0.0)
.slippage_pct(0.0)
.risk_free_rate(0.05)
.build()
.unwrap();
let engine_no_rf = BacktestEngine::new(config_no_rf);
let engine_with_rf = BacktestEngine::new(config_with_rf);
let result_no_rf = engine_no_rf
.run_with_benchmark(
"TEST",
&candles,
SmaCrossover::new(3, 10),
&[],
"BENCH",
&candles,
)
.unwrap();
let result_with_rf = engine_with_rf
.run_with_benchmark(
"TEST",
&candles,
SmaCrossover::new(3, 10),
&[],
"BENCH",
&candles,
)
.unwrap();
let bm_no_rf = result_no_rf.benchmark.unwrap();
let bm_with_rf = result_with_rf.benchmark.unwrap();
assert!(bm_no_rf.alpha.is_finite(), "Alpha should be finite");
assert!(
bm_with_rf.alpha.is_finite(),
"Alpha should be finite with rf"
);
assert!(
bm_no_rf.alpha.abs() < 50.0,
"Alpha should be small for identical strategy/benchmark"
);
assert!(
bm_with_rf.alpha.abs() < 50.0,
"Alpha should be small for identical strategy/benchmark with rf"
);
}
#[test]
fn test_run_with_benchmark_credits_dividends() {
use crate::models::chart::Dividend;
let prices: Vec<f64> = (0..30).map(|i| 100.0 + i as f64).collect();
let candles = make_candles(&prices);
let mid_ts = candles[15].timestamp;
let dividends = vec![Dividend {
timestamp: mid_ts,
amount: 1.0,
provider_id: None,
}];
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run_with_benchmark(
"TEST",
&candles,
SmaCrossover::new(3, 6),
÷nds,
"BENCH",
&candles,
)
.unwrap();
assert!(result.benchmark.is_some());
let total_div: f64 = result.trades.iter().map(|t| t.dividend_income).sum();
assert!(total_div >= 0.0);
}
#[test]
fn test_benchmark_beta_and_ir_require_timestamp_overlap() {
let symbol_candles = make_candles_with_timestamps(&[100.0, 110.0, 120.0], &[100, 200, 300]);
let benchmark_candles =
make_candles_with_timestamps(&[50.0, 55.0, 60.0, 65.0], &[1000, 1100, 1200, 1300]);
let config = BacktestConfig::builder()
.initial_capital(10_000.0)
.commission_pct(0.0)
.slippage_pct(0.0)
.risk_free_rate(0.05)
.build()
.unwrap();
let engine = BacktestEngine::new(config);
let result = engine
.run_with_benchmark(
"TEST",
&symbol_candles,
EnterLongHold,
&[],
"BENCH",
&benchmark_candles,
)
.unwrap();
let benchmark = result.benchmark.unwrap();
assert!((benchmark.beta - 0.0).abs() < 1e-12);
assert!((benchmark.information_ratio - 0.0).abs() < 1e-12);
assert!(
(benchmark.alpha - 0.0).abs() < 1e-12,
"alpha should be 0.0 with no timestamp overlap, got {}",
benchmark.alpha
);
}
}