修正回测出入金现金流中性口径
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
|
- Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。
|
||||||
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
|
- 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。
|
||||||
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
|
- 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。
|
||||||
|
- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。
|
||||||
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
|
- 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。
|
||||||
|
|
||||||
## Workspace 布局
|
## Workspace 布局
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ pub struct DailyEquityPoint {
|
|||||||
pub cash: f64,
|
pub cash: f64,
|
||||||
pub market_value: f64,
|
pub market_value: f64,
|
||||||
pub total_equity: 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_close: f64,
|
||||||
pub benchmark_prev_close: f64,
|
pub benchmark_prev_close: f64,
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
@@ -230,27 +237,34 @@ impl BacktestResult {
|
|||||||
|
|
||||||
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
|
pub fn analyzer_monthly_returns(&self) -> Vec<AnalyzerMonthlyReturnRow> {
|
||||||
let mut month_points = BTreeMap::<(i32, u32), (f64, f64, f64, f64)>::new();
|
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
|
let mut previous_benchmark = self
|
||||||
.equity_curve
|
.equity_curve
|
||||||
.first()
|
.first()
|
||||||
.map(|point| point.benchmark_prev_close)
|
.map(|point| point.benchmark_prev_close)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
for point in &self.equity_curve {
|
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());
|
let key = (point.date.year(), point.date.month());
|
||||||
month_points
|
month_points
|
||||||
.entry(key)
|
.entry(key)
|
||||||
.and_modify(|(_, _, end_equity, end_benchmark)| {
|
.and_modify(|(_, _, end_equity, end_benchmark)| {
|
||||||
*end_equity = point.total_equity;
|
*end_equity = point_nav;
|
||||||
*end_benchmark = point.benchmark_close;
|
*end_benchmark = point.benchmark_close;
|
||||||
})
|
})
|
||||||
.or_insert((
|
.or_insert((
|
||||||
previous_equity,
|
previous_equity,
|
||||||
previous_benchmark,
|
previous_benchmark,
|
||||||
point.total_equity,
|
point_nav,
|
||||||
point.benchmark_close,
|
point.benchmark_close,
|
||||||
));
|
));
|
||||||
previous_equity = point.total_equity;
|
previous_equity = point_nav;
|
||||||
previous_benchmark = point.benchmark_close;
|
previous_benchmark = point.benchmark_close;
|
||||||
}
|
}
|
||||||
month_points
|
month_points
|
||||||
@@ -299,6 +313,8 @@ pub struct BacktestDayProgress {
|
|||||||
pub cash: f64,
|
pub cash: f64,
|
||||||
pub market_value: f64,
|
pub market_value: f64,
|
||||||
pub total_equity: f64,
|
pub total_equity: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub external_cash_flow: f64,
|
||||||
pub unit_nav: f64,
|
pub unit_nav: f64,
|
||||||
pub total_return: f64,
|
pub total_return: f64,
|
||||||
pub benchmark_close: f64,
|
pub benchmark_close: f64,
|
||||||
@@ -1729,6 +1745,7 @@ where
|
|||||||
metrics: BacktestMetrics::default(),
|
metrics: BacktestMetrics::default(),
|
||||||
};
|
};
|
||||||
let mut stock_equity_by_date = BTreeMap::<NaiveDate, f64>::new();
|
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() {
|
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
||||||
let mut corporate_action_notes = Vec::new();
|
let mut corporate_action_notes = Vec::new();
|
||||||
@@ -1740,7 +1757,7 @@ where
|
|||||||
execution_date,
|
execution_date,
|
||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&mut corporate_action_notes,
|
&mut corporate_action_notes,
|
||||||
);
|
)?;
|
||||||
self.extend_result(
|
self.extend_result(
|
||||||
&mut result,
|
&mut result,
|
||||||
pending_cash_flow_report,
|
pending_cash_flow_report,
|
||||||
@@ -1829,16 +1846,21 @@ where
|
|||||||
.join(" | ");
|
.join(" | ");
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
let day_process_events = process_events.clone();
|
let day_process_events = process_events.clone();
|
||||||
let aggregate_initial_cash = self.aggregate_initial_cash();
|
|
||||||
let aggregate_cash = self.aggregate_cash(&portfolio);
|
let aggregate_cash = self.aggregate_cash(&portfolio);
|
||||||
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
||||||
let aggregate_total_equity = self.aggregate_total_equity(&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 {
|
result.equity_curve.push(DailyEquityPoint {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash: aggregate_cash,
|
cash: aggregate_cash,
|
||||||
market_value: aggregate_market_value,
|
market_value: aggregate_market_value,
|
||||||
total_equity: aggregate_total_equity,
|
total_equity: aggregate_total_equity,
|
||||||
|
external_cash_flow,
|
||||||
|
unit_nav,
|
||||||
benchmark_close: benchmark.close,
|
benchmark_close: benchmark.close,
|
||||||
benchmark_prev_close: benchmark.prev_close,
|
benchmark_prev_close: benchmark.prev_close,
|
||||||
notes,
|
notes,
|
||||||
@@ -1854,16 +1876,9 @@ where
|
|||||||
cash: latest.cash,
|
cash: latest.cash,
|
||||||
market_value: latest.market_value,
|
market_value: latest.market_value,
|
||||||
total_equity: latest.total_equity,
|
total_equity: latest.total_equity,
|
||||||
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
|
external_cash_flow: latest.external_cash_flow,
|
||||||
0.0
|
unit_nav: latest.unit_nav,
|
||||||
} else {
|
total_return: latest.unit_nav - 1.0,
|
||||||
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
|
|
||||||
},
|
|
||||||
benchmark_close: latest.benchmark_close,
|
benchmark_close: latest.benchmark_close,
|
||||||
daily_fill_count,
|
daily_fill_count,
|
||||||
cumulative_trade_count: result.fills.len(),
|
cumulative_trade_count: result.fills.len(),
|
||||||
@@ -2851,16 +2866,21 @@ where
|
|||||||
.join(" | ");
|
.join(" | ");
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
let day_process_events = process_events.clone();
|
let day_process_events = process_events.clone();
|
||||||
let aggregate_initial_cash = self.aggregate_initial_cash();
|
|
||||||
let aggregate_cash = self.aggregate_cash(&portfolio);
|
let aggregate_cash = self.aggregate_cash(&portfolio);
|
||||||
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
let aggregate_market_value = self.aggregate_market_value(&portfolio);
|
||||||
let aggregate_total_equity = self.aggregate_total_equity(&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 {
|
result.equity_curve.push(DailyEquityPoint {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
cash: aggregate_cash,
|
cash: aggregate_cash,
|
||||||
market_value: aggregate_market_value,
|
market_value: aggregate_market_value,
|
||||||
total_equity: aggregate_total_equity,
|
total_equity: aggregate_total_equity,
|
||||||
|
external_cash_flow,
|
||||||
|
unit_nav,
|
||||||
benchmark_close: benchmark.close,
|
benchmark_close: benchmark.close,
|
||||||
benchmark_prev_close: benchmark.prev_close,
|
benchmark_prev_close: benchmark.prev_close,
|
||||||
notes,
|
notes,
|
||||||
@@ -2876,16 +2896,9 @@ where
|
|||||||
cash: latest.cash,
|
cash: latest.cash,
|
||||||
market_value: latest.market_value,
|
market_value: latest.market_value,
|
||||||
total_equity: latest.total_equity,
|
total_equity: latest.total_equity,
|
||||||
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
|
external_cash_flow: latest.external_cash_flow,
|
||||||
0.0
|
unit_nav: latest.unit_nav,
|
||||||
} else {
|
total_return: latest.unit_nav - 1.0,
|
||||||
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
|
|
||||||
},
|
|
||||||
benchmark_close: latest.benchmark_close,
|
benchmark_close: latest.benchmark_close,
|
||||||
daily_fill_count,
|
daily_fill_count,
|
||||||
cumulative_trade_count: result.fills.len(),
|
cumulative_trade_count: result.fills.len(),
|
||||||
@@ -3205,9 +3218,12 @@ where
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
portfolio: &mut PortfolioState,
|
portfolio: &mut PortfolioState,
|
||||||
notes: &mut Vec<String>,
|
notes: &mut Vec<String>,
|
||||||
) -> BrokerExecutionReport {
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||||
let mut report = BrokerExecutionReport::default();
|
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 cash_before = portfolio.cash() - flow.amount;
|
||||||
let note = format!(
|
let note = format!(
|
||||||
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
"deposit_withdraw_settled amount={:.2} payable_date={} reason={}",
|
||||||
@@ -3222,7 +3238,7 @@ where
|
|||||||
note,
|
note,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
report
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
fn settle_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport {
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ pub struct BacktestMetrics {
|
|||||||
pub cash_balance: f64,
|
pub cash_balance: f64,
|
||||||
pub unit_nav: f64,
|
pub unit_nav: f64,
|
||||||
pub initial_cash: 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 excess_win_rate: f64,
|
||||||
pub monthly_sharpe: f64,
|
pub monthly_sharpe: f64,
|
||||||
pub monthly_volatility: f64,
|
pub monthly_volatility: f64,
|
||||||
@@ -81,12 +86,16 @@ pub fn compute_backtest_metrics(
|
|||||||
} else {
|
} else {
|
||||||
first_point.benchmark_close
|
first_point.benchmark_close
|
||||||
};
|
};
|
||||||
let mut returns = Vec::with_capacity(equity_curve.len());
|
let nav_series = equity_curve
|
||||||
returns.push(pct_change(initial_cash, first_point.total_equity));
|
.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(
|
returns.extend(
|
||||||
equity_curve
|
nav_series
|
||||||
.windows(2)
|
.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());
|
let mut benchmark_returns = Vec::with_capacity(equity_curve.len());
|
||||||
benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close));
|
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
|
last_point.benchmark_close / benchmark_start
|
||||||
};
|
};
|
||||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
let final_nav = *nav_series.last().unwrap_or(&1.0);
|
||||||
0.0
|
let total_return = final_nav - 1.0;
|
||||||
} else {
|
|
||||||
(last_point.total_equity / initial_cash) - 1.0
|
|
||||||
};
|
|
||||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||||
total_return
|
total_return
|
||||||
} else {
|
} 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 excess_return = total_return - benchmark_cumulative_return;
|
||||||
let annual_return = annualize_return(total_return, trade_days);
|
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 excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
||||||
|
|
||||||
let equity_nav = equity_curve
|
let equity_nav = nav_series;
|
||||||
.iter()
|
|
||||||
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let benchmark_nav_series = equity_curve
|
let benchmark_nav_series = equity_curve
|
||||||
.iter()
|
.iter()
|
||||||
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
|
.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 excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
||||||
|
|
||||||
let monthly_portfolio_returns =
|
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 =
|
let monthly_benchmark_returns =
|
||||||
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close);
|
||||||
let monthly_excess_returns = monthly_portfolio_returns
|
let monthly_excess_returns = monthly_portfolio_returns
|
||||||
@@ -257,14 +260,26 @@ pub fn compute_backtest_metrics(
|
|||||||
average_daily_turnover,
|
average_daily_turnover,
|
||||||
total_assets: last_point.total_equity,
|
total_assets: last_point.total_equity,
|
||||||
cash_balance: last_point.cash,
|
cash_balance: last_point.cash,
|
||||||
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
unit_nav: final_nav,
|
||||||
initial_cash,
|
initial_cash,
|
||||||
|
external_cash_flow_total: equity_curve
|
||||||
|
.iter()
|
||||||
|
.map(|point| point.external_cash_flow)
|
||||||
|
.sum(),
|
||||||
excess_win_rate,
|
excess_win_rate,
|
||||||
monthly_sharpe,
|
monthly_sharpe,
|
||||||
monthly_volatility,
|
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 {
|
fn pct_change(previous: f64, current: f64) -> f64 {
|
||||||
if previous.abs() < f64::EPSILON {
|
if previous.abs() < f64::EPSILON {
|
||||||
0.0
|
0.0
|
||||||
@@ -486,6 +501,8 @@ mod tests {
|
|||||||
cash: total_equity,
|
cash: total_equity,
|
||||||
market_value: 0.0,
|
market_value: 0.0,
|
||||||
total_equity,
|
total_equity,
|
||||||
|
external_cash_flow: 0.0,
|
||||||
|
unit_nav: total_equity / 100.0,
|
||||||
benchmark_close,
|
benchmark_close,
|
||||||
benchmark_prev_close,
|
benchmark_prev_close,
|
||||||
notes: String::new(),
|
notes: String::new(),
|
||||||
@@ -503,4 +520,27 @@ mod tests {
|
|||||||
let expected = 7595.285 / 5957.717 - 1.0;
|
let expected = 7595.285 / 5957.717 - 1.0;
|
||||||
assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,6 +432,11 @@ pub struct PortfolioState {
|
|||||||
initial_cash: f64,
|
initial_cash: f64,
|
||||||
units: f64,
|
units: f64,
|
||||||
cash: 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,
|
cash_liabilities: f64,
|
||||||
management_fee_rate: f64,
|
management_fee_rate: f64,
|
||||||
management_fees: f64,
|
management_fees: f64,
|
||||||
@@ -465,6 +470,7 @@ impl PortfolioState {
|
|||||||
initial_cash,
|
initial_cash,
|
||||||
units: initial_cash,
|
units: initial_cash,
|
||||||
cash: initial_cash,
|
cash: initial_cash,
|
||||||
|
external_cash_flow_total: 0.0,
|
||||||
cash_liabilities: 0.0,
|
cash_liabilities: 0.0,
|
||||||
management_fee_rate: 0.0,
|
management_fee_rate: 0.0,
|
||||||
management_fees: 0.0,
|
management_fees: 0.0,
|
||||||
@@ -476,7 +482,9 @@ impl PortfolioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn starting_cash(&self) -> f64 {
|
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 {
|
pub fn initial_cash(&self) -> f64 {
|
||||||
@@ -491,6 +499,10 @@ impl PortfolioState {
|
|||||||
self.cash
|
self.cash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn external_cash_flow_total(&self) -> f64 {
|
||||||
|
self.external_cash_flow_total
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cash_liabilities(&self) -> f64 {
|
pub fn cash_liabilities(&self) -> f64 {
|
||||||
self.cash_liabilities
|
self.cash_liabilities
|
||||||
}
|
}
|
||||||
@@ -549,15 +561,17 @@ impl PortfolioState {
|
|||||||
if !amount.is_finite() {
|
if !amount.is_finite() {
|
||||||
return Err("deposit_withdraw amount must be finite".to_string());
|
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!(
|
return Err(format!(
|
||||||
"insufficient cash for withdrawal amount={:.2} cash={:.2}",
|
"insufficient cash for withdrawal amount={:.2} available_cash={:.2}",
|
||||||
amount, self.cash
|
amount, available_cash
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let unit_net_value = self.unit_net_value();
|
let unit_net_value = self.unit_net_value();
|
||||||
self.cash += amount;
|
self.cash += amount;
|
||||||
|
self.external_cash_flow_total += amount;
|
||||||
self.rebase_units_after_external_cash_flow(unit_net_value);
|
self.rebase_units_after_external_cash_flow(unit_net_value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -571,10 +585,11 @@ impl PortfolioState {
|
|||||||
if !amount.is_finite() {
|
if !amount.is_finite() {
|
||||||
return Err("deposit_withdraw amount must be finite".to_string());
|
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!(
|
return Err(format!(
|
||||||
"insufficient cash for scheduled withdrawal amount={:.2} cash={:.2}",
|
"insufficient cash for scheduled withdrawal amount={:.2} available_cash={:.2}",
|
||||||
amount, self.cash
|
amount, available_cash
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.pending_cash_flows.push(PendingCashFlow {
|
self.pending_cash_flows.push(PendingCashFlow {
|
||||||
@@ -587,27 +602,74 @@ impl PortfolioState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn settle_pending_cash_flows(&mut self, date: NaiveDate) -> Vec<PendingCashFlow> {
|
pub fn settle_pending_cash_flows(
|
||||||
let mut settled = Vec::new();
|
&mut self,
|
||||||
|
date: NaiveDate,
|
||||||
|
) -> Result<Vec<PendingCashFlow>, String> {
|
||||||
|
let mut due = Vec::new();
|
||||||
let mut pending = Vec::new();
|
let mut pending = Vec::new();
|
||||||
for flow in std::mem::take(&mut self.pending_cash_flows) {
|
for flow in std::mem::take(&mut self.pending_cash_flows) {
|
||||||
if flow.payable_date <= date {
|
if flow.payable_date <= date {
|
||||||
let unit_net_value = self.unit_net_value();
|
due.push(flow);
|
||||||
self.cash += flow.amount;
|
|
||||||
self.rebase_units_after_external_cash_flow(unit_net_value);
|
|
||||||
settled.push(flow);
|
|
||||||
} else {
|
} else {
|
||||||
pending.push(flow);
|
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;
|
self.pending_cash_flows = pending;
|
||||||
settled
|
Ok(settled)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_cash_flows(&self) -> &[PendingCashFlow] {
|
pub fn pending_cash_flows(&self) -> &[PendingCashFlow] {
|
||||||
&self.pending_cash_flows
|
&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> {
|
pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> {
|
||||||
if !amount.is_finite() {
|
if !amount.is_finite() {
|
||||||
return Err("finance_repay amount must be finite".to_string());
|
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!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6);
|
||||||
assert_eq!(portfolio.cash_receivables().len(), 0);
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@@ -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: "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: "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: "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: "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: "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() },
|
ManualFunction { name: "rolling_sum / rolling_min / rolling_max".to_string(), signature: "rolling_sum(\"volume\", 20)".to_string(), detail: "任意数值字段滚动求和、最小值、最大值。第一个参数必须是字段名或字符串字段名,不能传嵌套表达式或另一个 helper 调用。可用于量能收缩、区间高低点、资金活跃度等过滤或排序。".to_string() },
|
||||||
|
|||||||
@@ -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[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].cash - 12_416.0).abs() < 1e-6);
|
||||||
assert!((result.equity_curve[1].total_equity - 11_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| {
|
assert!(result.account_events.iter().any(|event| {
|
||||||
event
|
event
|
||||||
.note
|
.note
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ futures path. Confirmed aligned areas:
|
|||||||
pending limit orders, cancellation, open order views, and final order lookup.
|
pending limit orders, cancellation, open order views, and final order lookup.
|
||||||
- Stock account and portfolio runtime fields including cash, frozen cash, total
|
- Stock account and portfolio runtime fields including cash, frozen cash, total
|
||||||
value, transaction cost, trading/position PnL, management fees, financing
|
value, transaction cost, trading/position PnL, management fees, financing
|
||||||
liability, deposit/withdraw, and position aliases.
|
liability, deposit/withdraw, and position aliases. External deposits and
|
||||||
|
withdrawals are unitized separately from trading PnL; delayed withdrawals
|
||||||
|
are preflighted atomically at settlement.
|
||||||
- Scheduler, dynamic universe, subscription guard, `history_bars`,
|
- Scheduler, dynamic universe, subscription guard, `history_bars`,
|
||||||
`current_snapshot`, `get_price`, instruments, trading-date APIs, suspension
|
`current_snapshot`, `get_price`, instruments, trading-date APIs, suspension
|
||||||
and ST helpers.
|
and ST helpers.
|
||||||
|
|||||||
Reference in New Issue
Block a user