From 6fba34d2e4802328c856d433006e14a5e15ac11a Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 22 Aug 2026 18:54:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9E=E6=B5=8B=E5=87=BA?= =?UTF-8?q?=E5=85=A5=E9=87=91=E7=8E=B0=E9=87=91=E6=B5=81=E4=B8=AD=E6=80=A7?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + crates/fidc-core/src/engine.rs | 76 ++++++++------ crates/fidc-core/src/metrics.rs | 72 ++++++++++--- crates/fidc-core/src/portfolio.rs | 135 ++++++++++++++++++++++--- crates/fidc-core/src/strategy_ai.rs | 2 +- crates/fidc-core/tests/engine_hooks.rs | 7 ++ docs/engine-capability-roadmap.md | 4 +- 7 files changed, 235 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index d1fffef..41569e5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - Broker 支持目标权重、显式金额、目标股数、限价、VWAP/TWAP、挂单、撤单和订单查询。 - 期货账户支持多空持仓、开平仓、今昨仓、保证金、手续费、结算和到期处理。 - 报告输出支持权益曲线、成交、持仓、月度收益、风险指标、基准序列和 JSON 分析包。 +- 账户出入金以独立外部现金流记录保存;权益曲线同时输出 `externalCashFlow` 和现金流中性 `unitNav`,收益指标不把入金/出金计入交易收益。延迟出金在结算前做整批资金校验,不能把账户现金变成负数。 - 内置 `OmniMicroCapStrategy`,覆盖动态市值带、均线过滤、止损止盈、固定频率再平衡和盘中执行近似。 ## Workspace 布局 diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 2f85f60..2f891d0 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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 { 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::::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, - ) -> BrokerExecutionReport { + ) -> Result { 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 { diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index e21be97..2c2899c 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -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::>(); + 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::>(); + 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); + } } diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index a450164..803d512 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -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 { - let mut settled = Vec::new(); + pub fn settle_pending_cash_flows( + &mut self, + date: NaiveDate, + ) -> Result, 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::(); + let outgoing = due + .iter() + .filter(|flow| flow.amount < 0.0) + .map(|flow| flow.amount) + .sum::(); + 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)] diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 5e4a7fd..6caf69d 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -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() }, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 6d6f0ea..d720913 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -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 diff --git a/docs/engine-capability-roadmap.md b/docs/engine-capability-roadmap.md index 317a560..789d6ce 100644 --- a/docs/engine-capability-roadmap.md +++ b/docs/engine-capability-roadmap.md @@ -20,7 +20,9 @@ futures path. Confirmed aligned areas: pending limit orders, cancellation, open order views, and final order lookup. - Stock account and portfolio runtime fields including cash, frozen cash, total 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`, `current_snapshot`, `get_price`, instruments, trading-date APIs, suspension and ST helpers.