修正回测出入金现金流中性口径

This commit is contained in:
boris
2026-08-22 18:54:34 +08:00
parent fe7e0f397f
commit 6fba34d2e4
7 changed files with 235 additions and 62 deletions
+46 -30
View File
@@ -78,6 +78,13 @@ pub struct DailyEquityPoint {
pub cash: f64,
pub market_value: f64,
pub total_equity: f64,
/// External cash flow settled on this trading date (deposit positive,
/// withdrawal negative). Trading cash movements are excluded.
#[serde(default)]
pub external_cash_flow: f64,
/// Cash-flow-neutral unit NAV after all activity on this date.
#[serde(default)]
pub unit_nav: f64,
pub benchmark_close: f64,
pub benchmark_prev_close: f64,
pub notes: String,
@@ -230,27 +237,34 @@ 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_equity = 1.0;
let mut previous_benchmark = self
.equity_curve
.first()
.map(|point| point.benchmark_prev_close)
.unwrap_or_default();
for point in &self.equity_curve {
let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 {
point.unit_nav
} else if self.metrics.initial_cash.abs() > f64::EPSILON {
point.total_equity / self.metrics.initial_cash
} else {
1.0
};
let key = (point.date.year(), point.date.month());
month_points
.entry(key)
.and_modify(|(_, _, end_equity, end_benchmark)| {
*end_equity = point.total_equity;
*end_equity = point_nav;
*end_benchmark = point.benchmark_close;
})
.or_insert((
previous_equity,
previous_benchmark,
point.total_equity,
point_nav,
point.benchmark_close,
));
previous_equity = point.total_equity;
previous_equity = point_nav;
previous_benchmark = point.benchmark_close;
}
month_points
@@ -299,6 +313,8 @@ pub struct BacktestDayProgress {
pub cash: f64,
pub market_value: f64,
pub total_equity: f64,
#[serde(default)]
pub external_cash_flow: f64,
pub unit_nav: f64,
pub total_return: f64,
pub benchmark_close: f64,
@@ -1729,6 +1745,7 @@ where
metrics: BacktestMetrics::default(),
};
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total();
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
let mut corporate_action_notes = Vec::new();
@@ -1740,7 +1757,7 @@ where
execution_date,
&mut portfolio,
&mut corporate_action_notes,
);
)?;
self.extend_result(
&mut result,
pending_cash_flow_report,
@@ -1829,16 +1846,21 @@ where
.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
let day_process_events = process_events.clone();
let aggregate_initial_cash = self.aggregate_initial_cash();
let aggregate_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
let unit_nav = portfolio.unit_net_value();
let external_cash_flow =
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
total_equity: aggregate_total_equity,
external_cash_flow,
unit_nav,
benchmark_close: benchmark.close,
benchmark_prev_close: benchmark.prev_close,
notes,
@@ -1854,16 +1876,9 @@ where
cash: latest.cash,
market_value: latest.market_value,
total_equity: latest.total_equity,
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
latest.total_equity / aggregate_initial_cash
},
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
(latest.total_equity / aggregate_initial_cash) - 1.0
},
external_cash_flow: latest.external_cash_flow,
unit_nav: latest.unit_nav,
total_return: latest.unit_nav - 1.0,
benchmark_close: latest.benchmark_close,
daily_fill_count,
cumulative_trade_count: result.fills.len(),
@@ -2851,16 +2866,21 @@ where
.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
let day_process_events = process_events.clone();
let aggregate_initial_cash = self.aggregate_initial_cash();
let aggregate_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
let unit_nav = portfolio.unit_net_value();
let external_cash_flow =
portfolio.external_cash_flow_total() - previous_external_cash_flow_total;
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
total_equity: aggregate_total_equity,
external_cash_flow,
unit_nav,
benchmark_close: benchmark.close,
benchmark_prev_close: benchmark.prev_close,
notes,
@@ -2876,16 +2896,9 @@ where
cash: latest.cash,
market_value: latest.market_value,
total_equity: latest.total_equity,
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
latest.total_equity / aggregate_initial_cash
},
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
(latest.total_equity / aggregate_initial_cash) - 1.0
},
external_cash_flow: latest.external_cash_flow,
unit_nav: latest.unit_nav,
total_return: latest.unit_nav - 1.0,
benchmark_close: latest.benchmark_close,
daily_fill_count,
cumulative_trade_count: result.fills.len(),
@@ -3205,9 +3218,12 @@ where
date: NaiveDate,
portfolio: &mut PortfolioState,
notes: &mut Vec<String>,
) -> BrokerExecutionReport {
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
for flow in portfolio.settle_pending_cash_flows(date) {
for flow in portfolio
.settle_pending_cash_flows(date)
.map_err(BacktestError::Execution)?
{
let cash_before = portfolio.cash() - flow.amount;
let note = format!(
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
@@ -3222,7 +3238,7 @@ where
note,
});
}
report
Ok(report)
}
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {