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

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 {
+56 -16
View File
@@ -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);
}
}
+121 -14
View File
@@ -432,6 +432,11 @@ pub struct PortfolioState {
initial_cash: f64,
units: f64,
cash: f64,
/// Cumulative external cash flow (deposits positive, withdrawals negative).
/// Trading proceeds, dividends, fees and financing are deliberately not
/// included. The value is used by the engine to build a cash-flow-neutral
/// equity curve and is not a return measure itself.
external_cash_flow_total: f64,
cash_liabilities: f64,
management_fee_rate: f64,
management_fees: f64,
@@ -465,6 +470,7 @@ impl PortfolioState {
initial_cash,
units: initial_cash,
cash: initial_cash,
external_cash_flow_total: 0.0,
cash_liabilities: 0.0,
management_fee_rate: 0.0,
management_fees: 0.0,
@@ -476,7 +482,9 @@ impl PortfolioState {
}
pub fn starting_cash(&self) -> f64 {
self.units
// Keep the configured opening capital stable. External flows change
// `units`, not the meaning of this reporting field.
self.initial_cash
}
pub fn initial_cash(&self) -> f64 {
@@ -491,6 +499,10 @@ impl PortfolioState {
self.cash
}
pub fn external_cash_flow_total(&self) -> f64 {
self.external_cash_flow_total
}
pub fn cash_liabilities(&self) -> f64 {
self.cash_liabilities
}
@@ -549,15 +561,17 @@ impl PortfolioState {
if !amount.is_finite() {
return Err("deposit_withdraw amount must be finite".to_string());
}
if amount < 0.0 && self.cash + amount < -1e-6 {
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
let available_cash = self.cash - self.pending_withdrawal_total();
return Err(format!(
"insufficient cash for withdrawal amount={:.2} cash={:.2}",
amount, self.cash
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash
));
}
let unit_net_value = self.unit_net_value();
self.cash += amount;
self.external_cash_flow_total += amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
Ok(())
}
@@ -571,10 +585,11 @@ impl PortfolioState {
if !amount.is_finite() {
return Err("deposit_withdraw amount must be finite".to_string());
}
if amount < 0.0 && self.cash + amount < -1e-6 {
if amount < 0.0 && self.cash - self.pending_withdrawal_total() + amount < -1e-6 {
let available_cash = self.cash - self.pending_withdrawal_total();
return Err(format!(
"insufficient cash for scheduled withdrawal amount={:.2} cash={:.2}",
amount, self.cash
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
amount, available_cash
));
}
self.pending_cash_flows.push(PendingCashFlow {
@@ -587,27 +602,74 @@ impl PortfolioState {
Ok(())
}
pub fn settle_pending_cash_flows(&mut self, date: NaiveDate) -> Vec<PendingCashFlow> {
let mut settled = Vec::new();
pub fn settle_pending_cash_flows(
&mut self,
date: NaiveDate,
) -> Result<Vec<PendingCashFlow>, String> {
let mut due = Vec::new();
let mut pending = Vec::new();
for flow in std::mem::take(&mut self.pending_cash_flows) {
if flow.payable_date <= date {
let unit_net_value = self.unit_net_value();
self.cash += flow.amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
settled.push(flow);
due.push(flow);
} else {
pending.push(flow);
}
}
// A delayed withdrawal must not be allowed to make the account
// negative after trades on an earlier day. Validate the complete due
// batch before mutating either cash or the pending queue so a failed
// settlement is atomic and can be diagnosed/retried safely.
let incoming = due
.iter()
.filter(|flow| flow.amount > 0.0)
.map(|flow| flow.amount)
.sum::<f64>();
let outgoing = due
.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| flow.amount)
.sum::<f64>();
if self.cash + incoming + outgoing < -1e-6 {
self.pending_cash_flows = due.into_iter().chain(pending).collect();
self.pending_cash_flows
.sort_by_key(|flow| flow.payable_date);
return Err(format!(
"insufficient cash to settle delayed cash flows on {date}: cash={:.2} net_due={:.2}",
self.cash,
incoming + outgoing
));
}
// There is no sub-day ordering in the strategy contract for flows
// sharing a payable date. Apply deposits first, then withdrawals, so
// a same-day net-zero batch is deterministic and never fails merely
// because a withdrawal happened to be listed first.
due.sort_by_key(|flow| (flow.payable_date, flow.amount < 0.0));
let mut settled = Vec::with_capacity(due.len());
for flow in due {
let unit_net_value = self.unit_net_value();
self.cash += flow.amount;
self.external_cash_flow_total += flow.amount;
self.rebase_units_after_external_cash_flow(unit_net_value);
settled.push(flow);
}
self.pending_cash_flows = pending;
settled
Ok(settled)
}
pub fn pending_cash_flows(&self) -> &[PendingCashFlow] {
&self.pending_cash_flows
}
pub fn pending_withdrawal_total(&self) -> f64 {
self.pending_cash_flows
.iter()
.filter(|flow| flow.amount < 0.0)
.map(|flow| -flow.amount)
.sum()
}
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
if !amount.is_finite() {
return Err("finance_repay amount must be finite".to_string());
@@ -1583,6 +1645,51 @@ mod tests {
assert!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6);
assert_eq!(portfolio.cash_receivables().len(), 0);
}
#[test]
fn external_cash_flow_rebases_units_without_changing_nav() {
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.deposit_withdraw(5_000.0)
.expect("deposit should settle");
assert!((portfolio.cash() - 15_000.0).abs() < 1e-6);
assert!((portfolio.units() - 15_000.0).abs() < 1e-6);
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
assert!((portfolio.external_cash_flow_total() - 5_000.0).abs() < 1e-6);
portfolio
.deposit_withdraw(-2_000.0)
.expect("withdrawal should settle");
assert!((portfolio.cash() - 13_000.0).abs() < 1e-6);
assert!((portfolio.units() - 13_000.0).abs() < 1e-6);
assert!((portfolio.unit_net_value() - 1.0).abs() < 1e-12);
assert!((portfolio.external_cash_flow_total() - 3_000.0).abs() < 1e-6);
}
#[test]
fn delayed_withdrawals_are_reserved_and_settled_atomically() {
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio
.schedule_deposit_withdraw(date, -8_000.0, "first")
.expect("first withdrawal should reserve cash");
assert!((portfolio.pending_withdrawal_total() - 8_000.0).abs() < 1e-6);
assert!(
portfolio
.schedule_deposit_withdraw(date, -3_000.0, "overcommit")
.is_err()
);
// A strategy cannot spend the reserved cash by scheduling a second
// withdrawal; settlement remains safe even if earlier trading reduced
// the current cash balance.
portfolio.apply_cash_delta(-3_000.0);
let error = portfolio
.settle_pending_cash_flows(date)
.expect_err("settlement must reject an underfunded withdrawal batch");
assert!(error.contains("insufficient cash"));
assert_eq!(portfolio.pending_cash_flows().len(), 1);
assert!((portfolio.cash() - 7_000.0).abs() < 1e-6);
}
}
#[derive(Debug, Clone, Serialize)]
+1 -1
View File
@@ -366,7 +366,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "get_dominant_future / dominant_future / dominant_future_price".to_string(), signature: "dominant_future(\"IF\") / dominant_future_price(\"IF\", \"close\", lookback=1)".to_string(), detail: "主力合约 API。dominant_future 返回当前日期匹配前缀的主力期货合约代码;dominant_future_price 读取该主力合约最近 N 个交易日指定字段的最新价格。Rust Context 可用 ctx.get_dominant_future(...) 和 ctx.get_dominant_future_price(...)。".to_string() },
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() },
ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() },
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力".to_string() },
ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。回测中 deposit_withdraw 正数入金、负数出金receiving_days 大于 0 时按交易日延迟到账,并保持现金流中性净值不把外部资金流当成收益;回测 finance_repay 与 management_fee 按账户合同结算。模拟盘只接受由 runtime 明确返回的即时 deposit_withdraw,并通过幂等现金流账本落库;延迟流、融资/管理费动作必须显式失败。实盘禁止策略侧改变现金,必须以券商资产和已核验资金流水为真相,策略返回上述动作会在下单前 fail-closed".to_string() },
ManualFunction { name: "rolling_mean / sma / ma".to_string(), signature: "rolling_mean(\"field\", lookback) / ma(\"close\", 20)".to_string(), detail: "任意字段滚动均值,支持 close、volume、amount、turnover_ratio、effective_turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 和所有数值型 extra_factors。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。个股 close 使用当前交易日前已完成收盘序列,volume 使用当前交易日前已完成成交量序列;历史窗口不足时在选股过滤和买入仓位表达式中按不通过/0 仓处理。".to_string() },
ManualFunction { name: "vma".to_string(), signature: "vma(60)".to_string(), detail: "rolling_mean(\"volume\", lookback) 的便捷别名,用于任意窗口成交量均线,例如 vma(5) < vma(60)。".to_string() },
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
+7
View File
@@ -2648,6 +2648,13 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
assert!((result.equity_curve[0].total_equity - 10_458.0).abs() < 1e-6);
assert!((result.equity_curve[1].cash - 12_416.0).abs() < 1e-6);
assert!((result.equity_curve[1].total_equity - 11_416.0).abs() < 1e-6);
assert!((result.equity_curve[0].external_cash_flow - 500.0).abs() < 1e-6);
assert!((result.equity_curve[1].external_cash_flow - 1_000.0).abs() < 1e-6);
assert!((result.metrics.external_cash_flow_total - 1_500.0).abs() < 1e-6);
// The 1,500 external cash contribution must not be reported as a
// strategy return. Only the explicit management fee affects NAV here.
assert!(result.metrics.total_return < 0.0);
assert!(result.metrics.total_return > -0.01);
assert!(result.account_events.iter().any(|event| {
event
.note