diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index cc9f7dc..68167d0 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -917,6 +917,22 @@ where )); Ok(()) } + OrderIntent::DepositWithdraw { + amount, + receiving_days, + reason, + } => { + report.diagnostics.push(format!( + "engine_account_intent_skipped kind=deposit_withdraw amount={amount:.2} receiving_days={receiving_days} reason={reason}" + )); + Ok(()) + } + OrderIntent::FinanceRepay { amount, reason } => { + report.diagnostics.push(format!( + "engine_account_intent_skipped kind=finance_repay amount={amount:.2} reason={reason}" + )); + Ok(()) + } } } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index f456fe1..ff27d6b 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -157,10 +157,11 @@ where execution_date: NaiveDate, decision_date: NaiveDate, decision_index: usize, - portfolio: &PortfolioState, + portfolio: &mut PortfolioState, open_orders: &[crate::strategy::OpenOrderView], process_events: &mut Vec, decision: &mut crate::strategy::StrategyDecision, + directive_report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { if decision.order_intents.is_empty() { return Ok(()); @@ -282,6 +283,119 @@ where )?; } } + crate::strategy::OrderIntent::DepositWithdraw { + amount, + receiving_days, + reason, + } => { + let cash_before = portfolio.cash(); + if receiving_days == 0 { + portfolio + .deposit_withdraw(amount) + .map_err(BacktestError::Execution)?; + directive_report.account_events.push(AccountEvent { + date: execution_date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note: format!("deposit_withdraw amount={amount:.2} reason={reason}"), + }); + } else { + let payable_date = self + .data + .next_trading_date(execution_date, receiving_days) + .ok_or_else(|| { + BacktestError::Execution(format!( + "no trading date for deposit_withdraw receiving_days={receiving_days} from {execution_date}" + )) + })?; + portfolio + .schedule_deposit_withdraw(payable_date, amount, reason.clone()) + .map_err(BacktestError::Execution)?; + directive_report.account_events.push(AccountEvent { + date: execution_date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note: format!( + "deposit_withdraw_scheduled amount={amount:.2} payable_date={payable_date} reason={reason}" + ), + }); + } + decision.diagnostics.push(format!( + "account_deposit_withdraw amount={amount:.2} receiving_days={receiving_days}" + )); + publish_custom_process_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &*portfolio, + open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + ProcessEvent { + date: execution_date, + kind: ProcessEventKind::AccountDepositWithdraw, + order_id: None, + symbol: None, + side: None, + detail: format!( + "reason={reason} amount={amount:.2} receiving_days={receiving_days} cash_before={cash_before:.2} cash_after={:.2}", + portfolio.cash() + ), + }, + )?; + } + crate::strategy::OrderIntent::FinanceRepay { amount, reason } => { + let cash_before = portfolio.cash(); + let liabilities_before = portfolio.cash_liabilities(); + portfolio + .finance_repay(amount) + .map_err(BacktestError::Execution)?; + directive_report.account_events.push(AccountEvent { + date: execution_date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note: format!( + "finance_repay amount={amount:.2} liabilities_before={liabilities_before:.2} liabilities_after={:.2} reason={reason}", + portfolio.cash_liabilities() + ), + }); + decision.diagnostics.push(format!( + "account_finance_repay amount={amount:.2} liabilities={:.2}", + portfolio.cash_liabilities() + )); + publish_custom_process_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &*portfolio, + open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + process_events, + ProcessEvent { + date: execution_date, + kind: ProcessEventKind::AccountFinanceRepay, + order_id: None, + symbol: None, + side: None, + detail: format!( + "reason={reason} amount={amount:.2} cash_before={cash_before:.2} cash_after={:.2} liabilities_before={liabilities_before:.2} liabilities_after={:.2}", + portfolio.cash(), + portfolio.cash_liabilities() + ), + }, + )?; + } other => retained.push(other), } } @@ -352,6 +466,12 @@ where for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); portfolio.begin_trading_day(); + let pending_cash_flow_report = self.settle_pending_cash_flows( + execution_date, + &mut portfolio, + &mut corporate_action_notes, + ); + self.extend_result(&mut result, pending_cash_flow_report); let receivable_report = self.settle_cash_receivables( execution_date, &mut portfolio, @@ -377,6 +497,7 @@ where let (decision_index, decision_date) = decision_slot.unwrap_or((execution_idx, execution_date)); let mut process_events = Vec::new(); + let mut directive_report = BrokerExecutionReport::default(); let pre_open_orders = self.broker.open_order_views(); let schedule_rules = self.strategy.schedule_rules(); publish_phase_event( @@ -452,10 +573,11 @@ where execution_date, decision_date, decision_index, - &portfolio, + &mut portfolio, &pre_open_orders, &mut process_events, &mut before_trading_decision, + &mut directive_report, )?; publish_phase_event( &mut self.strategy, @@ -546,10 +668,11 @@ where execution_date, decision_date, decision_index, - &portfolio, + &mut portfolio, &pre_open_orders, &mut process_events, &mut auction_decision, + &mut directive_report, )?; let mut report = self.broker.execute( execution_date, @@ -738,10 +861,11 @@ where execution_date, decision_date, decision_index, - &portfolio, + &mut portfolio, &on_day_open_orders, &mut process_events, &mut decision, + &mut directive_report, )?; let mut intraday_report = @@ -888,10 +1012,11 @@ where execution_date, decision_date, decision_index, - &portfolio, + &mut portfolio, &tick_open_orders, &mut process_events, &mut tick_decision, + &mut directive_report, )?; let mut tick_report = self.broker.execute_between( execution_date, @@ -1024,10 +1149,11 @@ where execution_date, decision_date, decision_index, - &portfolio, + &mut portfolio, &post_trade_open_orders, &mut process_events, &mut after_trading_decision, + &mut directive_report, )?; let mut close_report = self.broker.after_trading(execution_date); publish_process_events( @@ -1151,10 +1277,11 @@ where execution_date, decision_date, decision_index, - &portfolio, + &mut portfolio, &post_close_open_orders, &mut process_events, &mut settlement_decision, + &mut directive_report, )?; publish_phase_event( &mut self.strategy, @@ -1172,6 +1299,7 @@ where ProcessEventKind::PostSettlement, "settlement:post", )?; + merge_broker_report(&mut report, directive_report); let daily_fill_count = report.fill_events.len(); let day_orders = report.order_events.clone(); let day_fills = report.fill_events.clone(); @@ -1542,6 +1670,31 @@ where Ok(report) } + fn settle_pending_cash_flows( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + notes: &mut Vec, + ) -> BrokerExecutionReport { + let mut report = BrokerExecutionReport::default(); + for flow in portfolio.settle_pending_cash_flows(date) { + let cash_before = portfolio.cash() - flow.amount; + let note = format!( + "deposit_withdraw_settled amount={:.2} payable_date={} reason={}", + flow.amount, flow.payable_date, flow.reason + ); + notes.push(note.clone()); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note, + }); + } + report + } + fn settle_delisted_positions( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 54352fe..61d5117 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -148,6 +148,8 @@ pub enum ProcessEventKind { UniverseUpdated, UniverseSubscribed, UniverseUnsubscribed, + AccountDepositWithdraw, + AccountFinanceRepay, } impl ProcessEventKind { @@ -187,6 +189,8 @@ impl ProcessEventKind { Self::UniverseUpdated => "universe_updated", Self::UniverseSubscribed => "universe_subscribed", Self::UniverseUnsubscribed => "universe_unsubscribed", + Self::AccountDepositWithdraw => "account_deposit_withdraw", + Self::AccountFinanceRepay => "account_finance_repay", } } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index c2651f4..56d72a7 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -35,11 +35,12 @@ pub use events::{ pub use instrument::Instrument; pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use platform_expr_strategy::{ - PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, - PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule, - PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, + PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, + PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, + PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, + PlatformUniverseActionKind, }; -pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; +pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use scheduler::{ ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 152317c..3ef0347 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -114,6 +114,12 @@ pub enum PlatformUniverseActionKind { Unsubscribe, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformAccountActionKind { + DepositWithdraw, + FinanceRepay, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlatformTradeAction { Order { @@ -139,6 +145,13 @@ pub enum PlatformTradeAction { when_expr: Option, reason: String, }, + Account { + kind: PlatformAccountActionKind, + amount_expr: String, + receiving_days_expr: Option, + when_expr: Option, + reason: String, + }, Cancel { kind: PlatformExplicitCancelKind, symbol: Option, @@ -3078,6 +3091,42 @@ impl PlatformExprStrategy { }, }); } + PlatformTradeAction::Account { + kind, + amount_expr, + receiving_days_expr, + when_expr, + reason, + } => { + if !self.action_when_matches(ctx, day, None, when_expr.as_deref())? { + continue; + } + let amount = self.eval_float(ctx, amount_expr, day, None, None)?; + if amount.abs() <= f64::EPSILON { + continue; + } + match kind { + PlatformAccountActionKind::DepositWithdraw => { + let receiving_days = receiving_days_expr + .as_deref() + .map(|expr| self.eval_i32(ctx, expr, day, None, None)) + .transpose()? + .unwrap_or(0) + .max(0) as usize; + intents.push(OrderIntent::DepositWithdraw { + amount, + receiving_days, + reason: reason.clone(), + }); + } + PlatformAccountActionKind::FinanceRepay => { + intents.push(OrderIntent::FinanceRepay { + amount, + reason: reason.clone(), + }); + } + } + } PlatformTradeAction::TargetPortfolioSmart { target_weights_expr, order_prices_expr, @@ -3807,9 +3856,10 @@ mod tests { use chrono::{NaiveDate, NaiveTime}; use super::{ - PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, - PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule, - PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, + PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, + PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, + PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, + PlatformUniverseActionKind, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, @@ -4008,12 +4058,19 @@ mod tests { when_expr: Some("allow_buy".to_string()), reason: "platform_cancel_symbol".to_string(), }, + PlatformTradeAction::Account { + kind: PlatformAccountActionKind::DepositWithdraw, + amount_expr: "cash * 0.01".to_string(), + receiving_days_expr: Some("1".to_string()), + when_expr: Some("daily_returns == 0.0".to_string()), + reason: "platform_deposit".to_string(), + }, ]; let mut strategy = PlatformExprStrategy::new(cfg); let decision = strategy.on_day(&ctx).expect("platform decision"); - assert_eq!(decision.order_intents.len(), 2); + assert_eq!(decision.order_intents.len(), 3); match &decision.order_intents[0] { crate::strategy::OrderIntent::Value { symbol, @@ -4033,6 +4090,18 @@ mod tests { } other => panic!("unexpected explicit cancel intent: {other:?}"), } + match &decision.order_intents[2] { + crate::strategy::OrderIntent::DepositWithdraw { + amount, + receiving_days, + reason, + } => { + assert!((*amount - 10_000.0).abs() < 1e-6); + assert_eq!(*receiving_days, 1); + assert_eq!(reason, "platform_deposit"); + } + other => panic!("unexpected explicit account intent: {other:?}"), + } assert!( decision .diagnostics diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index d412a19..7c6e2e3 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -308,9 +308,19 @@ impl Position { #[derive(Debug, Clone)] pub struct PortfolioState { initial_cash: f64, + units: f64, cash: f64, + cash_liabilities: f64, positions: IndexMap, cash_receivables: Vec, + pending_cash_flows: Vec, +} + +#[derive(Debug, Clone)] +pub struct PendingCashFlow { + pub payable_date: NaiveDate, + pub amount: f64, + pub reason: String, } #[derive(Debug, Clone)] @@ -328,24 +338,35 @@ impl PortfolioState { pub fn new(initial_cash: f64) -> Self { Self { initial_cash, + units: initial_cash, cash: initial_cash, + cash_liabilities: 0.0, positions: IndexMap::new(), cash_receivables: Vec::new(), + pending_cash_flows: Vec::new(), } } pub fn starting_cash(&self) -> f64 { + self.units + } + + pub fn initial_cash(&self) -> f64 { self.initial_cash } pub fn units(&self) -> f64 { - self.initial_cash + self.units } pub fn cash(&self) -> f64 { self.cash } + pub fn cash_liabilities(&self) -> f64 { + self.cash_liabilities + } + pub fn positions(&self) -> &IndexMap { &self.positions } @@ -377,6 +398,92 @@ impl PortfolioState { self.refresh_dividend_receivables(); } + pub fn deposit_withdraw(&mut self, amount: f64) -> Result<(), String> { + if !amount.is_finite() { + return Err("deposit_withdraw amount must be finite".to_string()); + } + if amount < 0.0 && self.cash + amount < -1e-6 { + return Err(format!( + "insufficient cash for withdrawal amount={:.2} cash={:.2}", + amount, self.cash + )); + } + + let unit_net_value = self.unit_net_value(); + self.cash += amount; + self.rebase_units_after_external_cash_flow(unit_net_value); + Ok(()) + } + + pub fn schedule_deposit_withdraw( + &mut self, + payable_date: NaiveDate, + amount: f64, + reason: impl Into, + ) -> Result<(), String> { + if !amount.is_finite() { + return Err("deposit_withdraw amount must be finite".to_string()); + } + if amount < 0.0 && self.cash + amount < -1e-6 { + return Err(format!( + "insufficient cash for scheduled withdrawal amount={:.2} cash={:.2}", + amount, self.cash + )); + } + self.pending_cash_flows.push(PendingCashFlow { + payable_date, + amount, + reason: reason.into(), + }); + self.pending_cash_flows + .sort_by_key(|flow| flow.payable_date); + Ok(()) + } + + pub fn settle_pending_cash_flows(&mut self, date: NaiveDate) -> Vec { + let mut settled = 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); + } else { + pending.push(flow); + } + } + self.pending_cash_flows = pending; + settled + } + + pub fn pending_cash_flows(&self) -> &[PendingCashFlow] { + &self.pending_cash_flows + } + + pub fn finance_repay(&mut self, amount: f64) -> Result<(), String> { + if !amount.is_finite() { + return Err("finance_repay amount must be finite".to_string()); + } + if amount > 0.0 { + self.cash_liabilities += amount; + self.cash += amount; + return Ok(()); + } + if amount < 0.0 { + let repay_amount = (-amount).min(self.cash_liabilities); + if repay_amount > self.cash + 1e-6 { + return Err(format!( + "insufficient cash for finance repay amount={:.2} cash={:.2}", + repay_amount, self.cash + )); + } + self.cash_liabilities -= repay_amount; + self.cash -= repay_amount; + } + Ok(()) + } + pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec { let mut settled = Vec::new(); let mut pending = Vec::new(); @@ -459,7 +566,7 @@ impl PortfolioState { } pub fn total_equity(&self) -> f64 { - self.cash + self.market_value() + self.cash + self.market_value() - self.cash_liabilities } pub fn total_value(&self) -> f64 { @@ -471,18 +578,18 @@ impl PortfolioState { } pub fn unit_net_value(&self) -> f64 { - if self.initial_cash.abs() < f64::EPSILON { + if self.units.abs() < f64::EPSILON { 0.0 } else { - self.total_equity() / self.initial_cash + self.total_equity() / self.units } } pub fn static_unit_net_value(&self) -> f64 { - if self.initial_cash.abs() < f64::EPSILON { + if self.units.abs() < f64::EPSILON { 0.0 } else { - (self.total_equity() - self.daily_pnl()) / self.initial_cash + (self.total_equity() - self.daily_pnl()) / self.units } } @@ -619,6 +726,12 @@ impl PortfolioState { position.set_dividend_receivable(per_symbol.get(symbol).copied().unwrap_or(0.0)); } } + + fn rebase_units_after_external_cash_flow(&mut self, unit_net_value_before: f64) { + if unit_net_value_before > 0.0 && unit_net_value_before.is_finite() { + self.units = self.total_equity() / unit_net_value_before; + } + } } #[cfg(test)] diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 7c6ee8d..ae3102d 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -367,7 +367,7 @@ impl StrategyContext<'_> { transaction_cost: self.portfolio.transaction_cost(), trading_pnl: self.portfolio.trading_pnl(), position_pnl: self.portfolio.position_pnl(), - cash_liabilities: 0.0, + cash_liabilities: self.portfolio.cash_liabilities(), } } @@ -810,6 +810,15 @@ pub enum OrderIntent { symbols: BTreeSet, reason: String, }, + DepositWithdraw { + amount: f64, + receiving_days: usize, + reason: String, + }, + FinanceRepay { + amount: f64, + reason: String, + }, } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index d9e0cad..f640f5b 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -124,7 +124,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { }, ManualSection { title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(), - detail: "支持显式下单、撤单、AlgoOrder 和动态 universe 管理。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 rqalpha 的 tick 订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 rqalpha 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), + detail: "支持显式下单、撤单、AlgoOrder、动态 universe 和账户资金动作。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段;需要模拟 rqalpha 的 tick 订阅保护时,可写 trading.subscription_guard(true),未订阅 symbol 的显式订单会被拦截,TargetPortfolioSmart + AlgoOrder 会过滤未订阅标的。用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule,然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.vwap_value(\"600000.SH\", cash * 0.25, \"09:31\", \"09:40\")、order.twap_percent(\"600000.SH\", 0.05, \"10:00\", \"10:30\")、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices=VWAPOrder(930, 940), valuation_prices={\"600000.SH\": prev_close})、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])、account.deposit_withdraw(100000, receiving_days=0)、account.finance_repay(50000)。其中 order.target_shares(...) 对应 rqalpha 的 order_to,order.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义;account.deposit_withdraw(...) 和 account.finance_repay(...) 对应 RQAlpha 账户出入金与融资/还款语义;order_prices 既可以是逐标的限价映射,也可以是 VWAPOrder/TWAPOrder 这类全局 AlgoOrder;order.vwap_* / order.twap_* 对应 rqalpha 的 AlgoOrder 时间窗订单风格,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、时间窗、限价、order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(), }, ManualSection { title: "when / unless / else".to_string(), @@ -208,6 +208,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".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;可用便捷函数读取状态、成交均价和费用,对齐 RQAlpha Order 的核心属性。".to_string() }, ManualFunction { name: "account/portfolio_view".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 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 中同名字段可直接使用。".to_string() }, + ManualFunction { name: "deposit_withdraw/finance_repay".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。".to_string() }, ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() }, ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() }, ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() }, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 6e77624..8b7968c 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -162,6 +162,8 @@ struct OrderInspectionStrategy { observed: Rc>>, } +struct AccountFlowStrategy; + impl Strategy for ScheduledProbeStrategy { fn name(&self) -> &str { "scheduled-probe" @@ -492,6 +494,44 @@ impl Strategy for OrderInspectionStrategy { } } +impl Strategy for AccountFlowStrategy { + fn name(&self) -> &str { + "account-flow" + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date != d(2025, 1, 2) { + return Ok(StrategyDecision::default()); + } + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![ + OrderIntent::FinanceRepay { + amount: 1_000.0, + reason: "borrow".to_string(), + }, + OrderIntent::DepositWithdraw { + amount: 500.0, + receiving_days: 0, + reason: "cash_in".to_string(), + }, + OrderIntent::DepositWithdraw { + amount: 1_000.0, + receiving_days: 1, + reason: "cash_in_next_day".to_string(), + }, + ], + notes: Vec::new(), + diagnostics: Vec::new(), + }) + } +} + #[test] fn engine_runs_strategy_hooks_in_daily_order() { let date1 = d(2025, 1, 2); @@ -1300,6 +1340,180 @@ fn strategy_context_exposes_rqalpha_style_account_runtime_view() { assert!((ctx.available_cash() - account.available_cash).abs() < 1e-6); } +#[test] +fn engine_applies_account_cash_flow_and_financing_intents() { + let date1 = d(2025, 1, 2); + let date2 = d(2025, 1, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![ + DailyMarketSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date2, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-03 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: date2, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date: date1, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date2, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: date1, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 100.0, + volume: 1_000_000, + }, + ], + ) + .expect("dataset"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Close, + ); + let mut engine = BacktestEngine::new( + data, + AccountFlowStrategy, + broker, + BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ); + + let result = engine.run().expect("backtest run"); + + assert!((result.equity_curve[0].cash - 11_500.0).abs() < 1e-6); + assert!((result.equity_curve[0].total_equity - 10_500.0).abs() < 1e-6); + assert!((result.equity_curve[1].cash - 12_500.0).abs() < 1e-6); + assert!((result.equity_curve[1].total_equity - 11_500.0).abs() < 1e-6); + assert!(result.account_events.iter().any(|event| { + event + .note + .contains("finance_repay amount=1000.00 liabilities_before=0.00") + })); + assert!(result.account_events.iter().any(|event| { + event + .note + .contains("deposit_withdraw_scheduled amount=1000.00") + })); + assert!(result.account_events.iter().any(|event| { + event + .note + .contains("deposit_withdraw_settled amount=1000.00") + })); + assert!(result.process_events.iter().any(|event| { + event.kind == ProcessEventKind::AccountFinanceRepay + && event.detail.contains("liabilities_after=1000.00") + })); +} + #[test] fn engine_rejects_pending_limit_orders_at_market_close() { let date1 = d(2025, 1, 2); diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index b3e1bad..dce4956 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -78,8 +78,9 @@ current alignment pass. - [x] `unit_net_value`, `static_unit_net_value`, `daily_pnl`, `daily_returns`, `total_returns`, `transaction_cost`, `trading_pnl`, and `position_pnl` exposed to strategy runtime and DSL -- [ ] explicit deposit / withdraw API -- [ ] financing liability / repay API +- [x] explicit deposit / withdraw API +- [x] financing liability / repay API +- [ ] management-fee callback parity ## Execution Order @@ -96,5 +97,5 @@ current alignment pass. ## Current Step Active implementation target: continue account parity after exposing the stock -account runtime view and core Portfolio fields; next gaps are explicit -deposit/withdraw and financing liability APIs. +account runtime view, core Portfolio fields, deposit/withdraw, and financing +liability APIs; next gap is management-fee callback parity.