Add account cash flow intents
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProcessEvent>,
|
||||
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<String>,
|
||||
) -> 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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
reason: String,
|
||||
},
|
||||
Account {
|
||||
kind: PlatformAccountActionKind,
|
||||
amount_expr: String,
|
||||
receiving_days_expr: Option<String>,
|
||||
when_expr: Option<String>,
|
||||
reason: String,
|
||||
},
|
||||
Cancel {
|
||||
kind: PlatformExplicitCancelKind,
|
||||
symbol: Option<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -308,9 +308,19 @@ impl Position {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortfolioState {
|
||||
initial_cash: f64,
|
||||
units: f64,
|
||||
cash: f64,
|
||||
cash_liabilities: f64,
|
||||
positions: IndexMap<String, Position>,
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
pending_cash_flows: Vec<PendingCashFlow>,
|
||||
}
|
||||
|
||||
#[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<String, Position> {
|
||||
&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<String>,
|
||||
) -> 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<PendingCashFlow> {
|
||||
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<CashReceivable> {
|
||||
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)]
|
||||
|
||||
@@ -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<String>,
|
||||
reason: String,
|
||||
},
|
||||
DepositWithdraw {
|
||||
amount: f64,
|
||||
receiving_days: usize,
|
||||
reason: String,
|
||||
},
|
||||
FinanceRepay {
|
||||
amount: f64,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -162,6 +162,8 @@ struct OrderInspectionStrategy {
|
||||
observed: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
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<StrategyDecision, fidc_core::BacktestError> {
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user