修正回测出入金现金流中性口径
This commit is contained in:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user