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

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
+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)]