fix benchmark return baseline

This commit is contained in:
boris
2026-04-24 00:17:20 -07:00
parent 47988cd7e7
commit e6621c1719
3 changed files with 87 additions and 20 deletions

View File

@@ -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,
})

View File

@@ -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<AnalyzerMonthlyReturnRow> {
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,
});

View File

@@ -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::<Vec<_>>();
let benchmark_returns = equity_curve
.windows(2)
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close))
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<F>(equity_curve: &[DailyEquityPoint], value_fn: F) -> Vec<f64>
fn group_monthly_returns<F>(
equity_curve: &[DailyEquityPoint],
initial_value: f64,
value_fn: F,
) -> Vec<f64>
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::<Vec<_>>();
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);
}
}