修正入金后的资金流收益指标
This commit is contained in:
@@ -2908,6 +2908,7 @@ where
|
||||
&result.equity_curve,
|
||||
&result.fills,
|
||||
&result.daily_holdings,
|
||||
&result.account_events,
|
||||
self.aggregate_initial_cash(),
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::DailyEquityPoint;
|
||||
use crate::events::FillEvent;
|
||||
use crate::events::{AccountEvent, FillEvent};
|
||||
use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
@@ -56,6 +56,7 @@ pub fn compute_backtest_metrics(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
daily_holdings: &[HoldingSummary],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> BacktestMetrics {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
@@ -81,12 +82,15 @@ 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 portfolio_nav = flow_neutral_nav_series(equity_curve, account_events, initial_cash);
|
||||
let mut returns = Vec::with_capacity(portfolio_nav.len());
|
||||
if let Some(first_nav) = portfolio_nav.first().copied() {
|
||||
returns.push(pct_change(1.0, first_nav));
|
||||
}
|
||||
returns.extend(
|
||||
equity_curve
|
||||
portfolio_nav
|
||||
.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 +111,11 @@ 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 total_return = portfolio_nav.last().copied().unwrap_or(0.0) - 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
|
||||
portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
@@ -132,10 +132,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 = portfolio_nav;
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
||||
@@ -155,7 +152,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_from_values(equity_curve, &equity_nav);
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
@@ -257,7 +254,7 @@ 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: equity_nav.last().copied().unwrap_or_default(),
|
||||
initial_cash,
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
@@ -384,6 +381,69 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
(max_drawdown, max_duration)
|
||||
}
|
||||
|
||||
fn flow_neutral_nav_series(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
) -> Vec<f64> {
|
||||
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::new();
|
||||
for event in account_events {
|
||||
if !(event.note.starts_with("deposit_withdraw amount=")
|
||||
|| event.note.starts_with("deposit_withdraw_settled amount="))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
*external_flow_by_date.entry(event.date).or_default() +=
|
||||
event.cash_after - event.cash_before;
|
||||
}
|
||||
|
||||
let mut units = initial_cash;
|
||||
let mut previous_equity = initial_cash;
|
||||
let mut navs = Vec::with_capacity(equity_curve.len());
|
||||
for point in equity_curve {
|
||||
let unit_nav_before_flow = safe_div(previous_equity, units, 1.0);
|
||||
let external_flow = external_flow_by_date
|
||||
.get(&point.date)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() {
|
||||
units += external_flow / unit_nav_before_flow;
|
||||
}
|
||||
let unit_nav = safe_div(point.total_equity, units, 0.0);
|
||||
navs.push(unit_nav);
|
||||
previous_equity = point.total_equity;
|
||||
}
|
||||
navs
|
||||
}
|
||||
|
||||
fn group_monthly_returns_from_values(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
values: &[f64],
|
||||
) -> Vec<f64> {
|
||||
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut previous_value = 1.0;
|
||||
for (point, value) in equity_curve.iter().zip(values.iter().copied()) {
|
||||
let key = (point.date.year(), point.date.month());
|
||||
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();
|
||||
keys.into_iter()
|
||||
.filter_map(|key| {
|
||||
let first = month_first.get(&key).copied().unwrap_or_default();
|
||||
let last = month_last.get(&key).copied().unwrap_or_default();
|
||||
if first.abs() < f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some((last / first) - 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_monthly_returns<F>(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
initial_value: f64,
|
||||
@@ -499,8 +559,26 @@ mod tests {
|
||||
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 metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0);
|
||||
let expected = 7595.285 / 5957.717 - 1.0;
|
||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_deposit_does_not_count_as_strategy_return() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-02", 100.0, 100.0, 100.0),
|
||||
equity_point("2025-01-03", 220.0, 100.0, 100.0),
|
||||
];
|
||||
let events = vec![AccountEvent {
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
|
||||
cash_before: 100.0,
|
||||
cash_after: 200.0,
|
||||
total_equity: 200.0,
|
||||
note: "deposit_withdraw amount=100.00 reason=test".to_string(),
|
||||
}];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0);
|
||||
assert!((metrics.total_return - 0.1).abs() < 1e-12);
|
||||
assert!((metrics.unit_nav - 1.1).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user