fix benchmark return baseline
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user