From e6621c171929737634a35a56ae65b336948317c8 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 24 Apr 2026 00:17:20 -0700 Subject: [PATCH] fix benchmark return baseline --- crates/bt-demo/src/main.rs | 6 ++- crates/fidc-core/src/engine.rs | 14 +++++- crates/fidc-core/src/metrics.rs | 87 +++++++++++++++++++++++++++------ 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs index 7f48a06..3a30c5c 100644 --- a/crates/bt-demo/src/main.rs +++ b/crates/bt-demo/src/main.rs @@ -194,17 +194,18 @@ fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), let mut file = fs::File::create(path)?; writeln!( file, - "date,cash,market_value,total_equity,benchmark_close,notes,diagnostics" + "date,cash,market_value,total_equity,benchmark_close,benchmark_prev_close,notes,diagnostics" )?; for row in rows { writeln!( file, - "{},{:.2},{:.2},{:.2},{:.2},{},{}", + "{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{}", row.date, row.cash, row.market_value, row.total_equity, row.benchmark_close, + row.benchmark_prev_close, sanitize_csv_field(&row.notes), sanitize_csv_field(&row.diagnostics), )?; @@ -317,6 +318,7 @@ fn build_summary( "marketValue": row.market_value, "totalEquity": row.total_equity, "benchmarkClose": row.benchmark_close, + "benchmarkPrevClose": row.benchmark_prev_close, "notes": row.notes, "diagnostics": row.diagnostics, }) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 7e5a5ce..8063cb7 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -75,6 +75,7 @@ pub struct DailyEquityPoint { pub market_value: f64, pub total_equity: f64, pub benchmark_close: f64, + pub benchmark_prev_close: f64, pub notes: String, pub diagnostics: String, } @@ -212,6 +213,12 @@ impl BacktestResult { pub fn analyzer_monthly_returns(&self) -> Vec { let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new(); + let mut previous_equity = self.metrics.initial_cash; + let mut previous_benchmark = self + .equity_curve + .first() + .map(|point| point.benchmark_prev_close) + .unwrap_or_default(); for point in &self.equity_curve { let key = (point.date.year(), point.date.month()); month_points @@ -221,11 +228,13 @@ impl BacktestResult { *end_benchmark = point.benchmark_close; }) .or_insert(( - point.total_equity, - point.benchmark_close, + previous_equity, + previous_benchmark, point.total_equity, point.benchmark_close, )); + previous_equity = point.total_equity; + previous_benchmark = point.benchmark_close; } month_points .into_iter() @@ -2423,6 +2432,7 @@ where market_value: aggregate_market_value, total_equity: aggregate_total_equity, benchmark_close: benchmark.close, + benchmark_prev_close: benchmark.prev_close, notes, diagnostics, }); diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index 390ae9d..e21be97 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -74,24 +74,37 @@ pub fn compute_backtest_metrics( }; let trade_days = equity_curve.len(); - let returns = equity_curve - .windows(2) - .map(|window| pct_change(window[0].total_equity, window[1].total_equity)) - .collect::>(); - let benchmark_returns = equity_curve - .windows(2) - .map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close)) - .collect::>(); + let benchmark_start = if first_point.benchmark_prev_close.is_finite() + && first_point.benchmark_prev_close > f64::EPSILON + { + first_point.benchmark_prev_close + } else { + first_point.benchmark_close + }; + let mut returns = Vec::with_capacity(equity_curve.len()); + returns.push(pct_change(initial_cash, first_point.total_equity)); + returns.extend( + equity_curve + .windows(2) + .map(|window| pct_change(window[0].total_equity, window[1].total_equity)), + ); + let mut benchmark_returns = Vec::with_capacity(equity_curve.len()); + benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close)); + benchmark_returns.extend( + equity_curve + .windows(2) + .map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close)), + ); let excess_returns = returns .iter() .zip(benchmark_returns.iter()) .map(|(lhs, rhs)| lhs - rhs) .collect::>(); - let benchmark_net_value = if first_point.benchmark_close.abs() < f64::EPSILON { + let benchmark_net_value = if benchmark_start.abs() < f64::EPSILON { 1.0 } else { - last_point.benchmark_close / first_point.benchmark_close + last_point.benchmark_close / benchmark_start }; let benchmark_cumulative_return = benchmark_net_value - 1.0; let total_return = if initial_cash.abs() < f64::EPSILON { @@ -125,7 +138,7 @@ pub fn compute_backtest_metrics( .collect::>(); let benchmark_nav_series = equity_curve .iter() - .map(|point| safe_div(point.benchmark_close, first_point.benchmark_close, 1.0)) + .map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0)) .collect::>(); let excess_nav_series = equity_nav .iter() @@ -141,9 +154,10 @@ pub fn compute_backtest_metrics( let win_rate = ratio(winning_days, returns.len()); let excess_win_rate = ratio(excess_winning_days, excess_returns.len()); - let monthly_portfolio_returns = group_monthly_returns(equity_curve, |point| point.total_equity); + let monthly_portfolio_returns = + group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity); let monthly_benchmark_returns = - group_monthly_returns(equity_curve, |point| point.benchmark_close); + group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close); let monthly_excess_returns = monthly_portfolio_returns .iter() .zip(monthly_benchmark_returns.iter()) @@ -370,16 +384,23 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) { (max_drawdown, max_duration) } -fn group_monthly_returns(equity_curve: &[DailyEquityPoint], value_fn: F) -> Vec +fn group_monthly_returns( + equity_curve: &[DailyEquityPoint], + initial_value: f64, + value_fn: F, +) -> Vec where F: Fn(&DailyEquityPoint) -> f64, { let mut month_last = BTreeMap::<(i32, u32), f64>::new(); let mut month_first = BTreeMap::<(i32, u32), f64>::new(); + let mut previous_value = initial_value; for point in equity_curve { let key = (point.date.year(), point.date.month()); - month_first.entry(key).or_insert_with(|| value_fn(point)); - month_last.insert(key, value_fn(point)); + let value = value_fn(point); + month_first.entry(key).or_insert(previous_value); + month_last.insert(key, value); + previous_value = value; } let mut keys = month_last.keys().copied().collect::>(); keys.sort_unstable(); @@ -449,3 +470,37 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 { numerator / denominator } } + +#[cfg(test)] +mod tests { + use super::*; + + fn equity_point( + date: &str, + total_equity: f64, + benchmark_close: f64, + benchmark_prev_close: f64, + ) -> DailyEquityPoint { + DailyEquityPoint { + date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), + cash: total_equity, + market_value: 0.0, + total_equity, + benchmark_close, + benchmark_prev_close, + notes: String::new(), + diagnostics: String::new(), + } + } + + #[test] + fn benchmark_cumulative_return_uses_first_day_previous_close() { + let curve = vec![ + 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 expected = 7595.285 / 5957.717 - 1.0; + assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12); + } +}