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)?;
|
let mut file = fs::File::create(path)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
file,
|
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 {
|
for row in rows {
|
||||||
writeln!(
|
writeln!(
|
||||||
file,
|
file,
|
||||||
"{},{:.2},{:.2},{:.2},{:.2},{},{}",
|
"{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{}",
|
||||||
row.date,
|
row.date,
|
||||||
row.cash,
|
row.cash,
|
||||||
row.market_value,
|
row.market_value,
|
||||||
row.total_equity,
|
row.total_equity,
|
||||||
row.benchmark_close,
|
row.benchmark_close,
|
||||||
|
row.benchmark_prev_close,
|
||||||
sanitize_csv_field(&row.notes),
|
sanitize_csv_field(&row.notes),
|
||||||
sanitize_csv_field(&row.diagnostics),
|
sanitize_csv_field(&row.diagnostics),
|
||||||
)?;
|
)?;
|
||||||
@@ -317,6 +318,7 @@ fn build_summary(
|
|||||||
"marketValue": row.market_value,
|
"marketValue": row.market_value,
|
||||||
"totalEquity": row.total_equity,
|
"totalEquity": row.total_equity,
|
||||||
"benchmarkClose": row.benchmark_close,
|
"benchmarkClose": row.benchmark_close,
|
||||||
|
"benchmarkPrevClose": row.benchmark_prev_close,
|
||||||
"notes": row.notes,
|
"notes": row.notes,
|
||||||
"diagnostics": row.diagnostics,
|
"diagnostics": row.diagnostics,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub struct DailyEquityPoint {
|
|||||||
pub market_value: f64,
|
pub market_value: f64,
|
||||||
pub total_equity: f64,
|
pub total_equity: f64,
|
||||||
pub benchmark_close: f64,
|
pub benchmark_close: f64,
|
||||||
|
pub benchmark_prev_close: f64,
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
pub diagnostics: String,
|
pub diagnostics: String,
|
||||||
}
|
}
|
||||||
@@ -212,6 +213,12 @@ impl BacktestResult {
|
|||||||
|
|
||||||
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
|
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
|
||||||
let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new();
|
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 {
|
for point in &self.equity_curve {
|
||||||
let key = (point.date.year(), point.date.month());
|
let key = (point.date.year(), point.date.month());
|
||||||
month_points
|
month_points
|
||||||
@@ -221,11 +228,13 @@ impl BacktestResult {
|
|||||||
*end_benchmark = point.benchmark_close;
|
*end_benchmark = point.benchmark_close;
|
||||||
})
|
})
|
||||||
.or_insert((
|
.or_insert((
|
||||||
point.total_equity,
|
previous_equity,
|
||||||
point.benchmark_close,
|
previous_benchmark,
|
||||||
point.total_equity,
|
point.total_equity,
|
||||||
point.benchmark_close,
|
point.benchmark_close,
|
||||||
));
|
));
|
||||||
|
previous_equity = point.total_equity;
|
||||||
|
previous_benchmark = point.benchmark_close;
|
||||||
}
|
}
|
||||||
month_points
|
month_points
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2423,6 +2432,7 @@ where
|
|||||||
market_value: aggregate_market_value,
|
market_value: aggregate_market_value,
|
||||||
total_equity: aggregate_total_equity,
|
total_equity: aggregate_total_equity,
|
||||||
benchmark_close: benchmark.close,
|
benchmark_close: benchmark.close,
|
||||||
|
benchmark_prev_close: benchmark.prev_close,
|
||||||
notes,
|
notes,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,24 +74,37 @@ pub fn compute_backtest_metrics(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let trade_days = equity_curve.len();
|
let trade_days = equity_curve.len();
|
||||||
let returns = equity_curve
|
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)
|
.windows(2)
|
||||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity))
|
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
|
||||||
.collect::<Vec<_>>();
|
);
|
||||||
let benchmark_returns = equity_curve
|
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)
|
.windows(2)
|
||||||
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close))
|
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close)),
|
||||||
.collect::<Vec<_>>();
|
);
|
||||||
let excess_returns = returns
|
let excess_returns = returns
|
||||||
.iter()
|
.iter()
|
||||||
.zip(benchmark_returns.iter())
|
.zip(benchmark_returns.iter())
|
||||||
.map(|(lhs, rhs)| lhs - rhs)
|
.map(|(lhs, rhs)| lhs - rhs)
|
||||||
.collect::<Vec<_>>();
|
.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
|
1.0
|
||||||
} else {
|
} 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 benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
let total_return = if initial_cash.abs() < f64::EPSILON {
|
||||||
@@ -125,7 +138,7 @@ pub fn compute_backtest_metrics(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let benchmark_nav_series = equity_curve
|
let benchmark_nav_series = equity_curve
|
||||||
.iter()
|
.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<_>>();
|
.collect::<Vec<_>>();
|
||||||
let excess_nav_series = equity_nav
|
let excess_nav_series = equity_nav
|
||||||
.iter()
|
.iter()
|
||||||
@@ -141,9 +154,10 @@ pub fn compute_backtest_metrics(
|
|||||||
let win_rate = ratio(winning_days, returns.len());
|
let win_rate = ratio(winning_days, returns.len());
|
||||||
let excess_win_rate = ratio(excess_winning_days, excess_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 =
|
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
|
let monthly_excess_returns = monthly_portfolio_returns
|
||||||
.iter()
|
.iter()
|
||||||
.zip(monthly_benchmark_returns.iter())
|
.zip(monthly_benchmark_returns.iter())
|
||||||
@@ -370,16 +384,23 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
|||||||
(max_drawdown, max_duration)
|
(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
|
where
|
||||||
F: Fn(&DailyEquityPoint) -> f64,
|
F: Fn(&DailyEquityPoint) -> f64,
|
||||||
{
|
{
|
||||||
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||||
let mut month_first = 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 {
|
for point in equity_curve {
|
||||||
let key = (point.date.year(), point.date.month());
|
let key = (point.date.year(), point.date.month());
|
||||||
month_first.entry(key).or_insert_with(|| value_fn(point));
|
let value = value_fn(point);
|
||||||
month_last.insert(key, 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<_>>();
|
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
|
||||||
keys.sort_unstable();
|
keys.sort_unstable();
|
||||||
@@ -449,3 +470,37 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
|||||||
numerator / denominator
|
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