修正回测出入金现金流中性口径
This commit is contained in:
@@ -47,6 +47,11 @@ pub struct BacktestMetrics {
|
||||
pub cash_balance: f64,
|
||||
pub unit_nav: f64,
|
||||
pub initial_cash: f64,
|
||||
/// Sum of external deposits (positive) and withdrawals (negative). This
|
||||
/// is reported separately so callers cannot mistake a cash transfer for
|
||||
/// trading performance.
|
||||
#[serde(default)]
|
||||
pub external_cash_flow_total: f64,
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
@@ -81,12 +86,16 @@ pub fn compute_backtest_metrics(
|
||||
} else {
|
||||
first_point.benchmark_close
|
||||
};
|
||||
let mut returns = Vec::with_capacity(equity_curve.len());
|
||||
returns.push(pct_change(initial_cash, first_point.total_equity));
|
||||
let nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| point_nav(point, initial_cash))
|
||||
.collect::<Vec<_>>();
|
||||
let mut returns = Vec::with_capacity(nav_series.len());
|
||||
returns.push(pct_change(1.0, nav_series[0]));
|
||||
returns.extend(
|
||||
equity_curve
|
||||
nav_series
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity)),
|
||||
.map(|window| pct_change(window[0], window[1])),
|
||||
);
|
||||
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
|
||||
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
|
||||
@@ -107,15 +116,12 @@ pub fn compute_backtest_metrics(
|
||||
last_point.benchmark_close / benchmark_start
|
||||
};
|
||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) - 1.0
|
||||
};
|
||||
let final_nav = *nav_series.last().unwrap_or(&1.0);
|
||||
let total_return = final_nav - 1.0;
|
||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||
total_return
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
||||
final_nav / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
@@ -132,10 +138,7 @@ pub fn compute_backtest_metrics(
|
||||
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
||||
|
||||
let equity_nav = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let equity_nav = nav_series;
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
||||
@@ -155,7 +158,7 @@ pub fn compute_backtest_metrics(
|
||||
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
||||
|
||||
let monthly_portfolio_returns =
|
||||
group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity);
|
||||
group_monthly_returns(equity_curve, 1.0, |point| point_nav(point, initial_cash));
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
@@ -257,14 +260,26 @@ pub fn compute_backtest_metrics(
|
||||
average_daily_turnover,
|
||||
total_assets: last_point.total_equity,
|
||||
cash_balance: last_point.cash,
|
||||
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
||||
unit_nav: final_nav,
|
||||
initial_cash,
|
||||
external_cash_flow_total: equity_curve
|
||||
.iter()
|
||||
.map(|point| point.external_cash_flow)
|
||||
.sum(),
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
monthly_volatility,
|
||||
}
|
||||
}
|
||||
|
||||
fn point_nav(point: &DailyEquityPoint, initial_cash: f64) -> f64 {
|
||||
if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
|
||||
point.unit_nav
|
||||
} else {
|
||||
safe_div(point.total_equity, initial_cash, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_change(previous: f64, current: f64) -> f64 {
|
||||
if previous.abs() < f64::EPSILON {
|
||||
0.0
|
||||
@@ -486,6 +501,8 @@ mod tests {
|
||||
cash: total_equity,
|
||||
market_value: 0.0,
|
||||
total_equity,
|
||||
external_cash_flow: 0.0,
|
||||
unit_nav: total_equity / 100.0,
|
||||
benchmark_close,
|
||||
benchmark_prev_close,
|
||||
notes: String::new(),
|
||||
@@ -503,4 +520,27 @@ mod tests {
|
||||
let expected = 7595.285 / 5957.717 - 1.0;
|
||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_cash_flow_is_excluded_from_return_and_reported_separately() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||
DailyEquityPoint {
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash: 200.0,
|
||||
market_value: 0.0,
|
||||
total_equity: 200.0,
|
||||
external_cash_flow: 100.0,
|
||||
unit_nav: 1.0,
|
||||
benchmark_close: 100.0,
|
||||
benchmark_prev_close: 100.0,
|
||||
notes: String::new(),
|
||||
diagnostics: String::new(),
|
||||
},
|
||||
];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
|
||||
assert!((metrics.total_return - 0.0).abs() < 1e-12);
|
||||
assert!((metrics.unit_nav - 1.0).abs() < 1e-12);
|
||||
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user