use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use serde::Serialize; use thiserror::Error; use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, IntradayExecutionQuote, PriceField}; use crate::event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; use crate::events::{ AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, ProcessEventKind, }; use crate::futures::{ FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect, FuturesTransactionCostModel, }; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::risk_control::FidcRiskDecisionAudit; use crate::rules::EquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time}; use crate::strategy::{ OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing, }; #[derive(Debug, Error)] pub enum BacktestError { #[error(transparent)] Data(#[from] DataSetError), #[error("missing {field} price for {symbol} on {date}")] MissingPrice { date: NaiveDate, symbol: String, field: &'static str, }, #[error("benchmark snapshot missing for {date}")] MissingBenchmark { date: NaiveDate }, #[error("{0}")] Execution(String), } #[derive(Debug, Clone)] pub struct BacktestConfig { pub initial_cash: f64, pub benchmark_code: String, pub start_date: Option, pub end_date: Option, pub decision_lag_trading_days: usize, pub execution_price_field: PriceField, } #[derive(Debug, Clone, Copy)] pub struct FuturesValidationConfig { pub enforce_active_instrument: bool, pub enforce_trading_phase: bool, pub enforce_limit_price_tick: bool, pub enforce_price_limits: bool, } impl Default for FuturesValidationConfig { fn default() -> Self { Self { enforce_active_instrument: true, enforce_trading_phase: true, enforce_limit_price_tick: true, enforce_price_limits: true, } } } #[derive(Debug, Clone, Serialize)] pub struct DailyEquityPoint { #[serde(with = "date_format")] pub date: NaiveDate, pub cash: f64, pub market_value: f64, pub total_equity: f64, pub benchmark_close: f64, pub benchmark_prev_close: f64, pub notes: String, pub diagnostics: String, } #[derive(Debug, Clone)] pub struct BacktestResult { pub strategy_name: String, pub equity_curve: Vec, pub benchmark_series: Vec, pub risk_decisions: Vec, pub order_events: Vec, pub fills: Vec, pub position_events: Vec, pub account_events: Vec, pub process_events: Vec, pub holdings_summary: Vec, pub daily_holdings: Vec, pub metrics: BacktestMetrics, } #[derive(Debug, Clone)] pub struct ExecutionQuoteRequest { pub date: NaiveDate, pub start_time: Option, pub end_time: Option, pub symbols: BTreeSet, } type ExecutionQuoteLoader = Box< dyn FnMut(ExecutionQuoteRequest) -> Result, BacktestError> + Send, >; #[derive(Debug, Clone, Serialize)] pub struct AnalyzerTradeRow { #[serde(with = "date_format")] pub date: NaiveDate, pub order_id: Option, pub symbol: String, pub side: OrderSide, pub quantity: u32, pub price: f64, pub gross_amount: f64, pub transaction_cost: f64, pub net_cash_flow: f64, pub reason: String, } #[derive(Debug, Clone, Serialize)] pub struct AnalyzerPositionRow { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, pub quantity: u32, pub market_value: f64, pub weight: f64, pub average_cost: f64, pub realized_pnl: f64, pub unrealized_pnl: f64, pub transaction_cost: f64, } #[derive(Debug, Clone, Serialize)] pub struct AnalyzerMonthlyReturnRow { pub year: i32, pub month: u32, pub portfolio_return: f64, pub benchmark_return: f64, pub excess_return: f64, } #[derive(Debug, Clone, Serialize)] pub struct AnalyzerRiskSummary { pub total_return: f64, pub annual_return: f64, pub benchmark_cumulative_return: f64, pub excess_cumulative_return: f64, pub alpha: f64, pub beta: f64, pub sharpe: f64, pub sortino: f64, pub information_ratio: f64, pub tracking_error: f64, pub volatility: f64, pub max_drawdown: f64, pub max_drawdown_duration_days: usize, pub win_rate: f64, pub excess_win_rate: f64, } #[derive(Debug, Clone, Serialize)] pub struct AnalyzerReport { pub strategy_name: String, pub trades: Vec, pub positions: Vec, pub monthly_returns: Vec, pub risk_summary: AnalyzerRiskSummary, pub equity_curve: Vec, pub benchmark_series: Vec, pub metrics: BacktestMetrics, } impl BacktestResult { pub fn analyzer_report(&self) -> AnalyzerReport { AnalyzerReport { strategy_name: self.strategy_name.clone(), trades: self .fills .iter() .map(|fill| AnalyzerTradeRow { date: fill.date, order_id: fill.order_id, symbol: fill.symbol.clone(), side: fill.side, quantity: fill.quantity, price: fill.price, gross_amount: fill.gross_amount, transaction_cost: fill.commission + fill.stamp_tax, net_cash_flow: fill.net_cash_flow, reason: fill.reason.clone(), }) .collect(), positions: self .daily_holdings .iter() .map(|holding| AnalyzerPositionRow { date: holding.date, symbol: holding.symbol.clone(), quantity: holding.quantity, market_value: holding.market_value, weight: holding.value_percent, average_cost: holding.average_cost, realized_pnl: holding.realized_pnl, unrealized_pnl: holding.unrealized_pnl, transaction_cost: holding.transaction_cost, }) .collect(), monthly_returns: self.analyzer_monthly_returns(), risk_summary: self.analyzer_risk_summary(), equity_curve: self.equity_curve.clone(), benchmark_series: self.benchmark_series.clone(), metrics: self.metrics.clone(), } } pub fn analyzer_report_json(&self) -> Result { serde_json::to_string_pretty(&self.analyzer_report()) } 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_benchmark = self .equity_curve .first() .map(|point| point.benchmark_prev_close) .unwrap_or_default(); for point in &self.equity_curve { let key = (point.date.year(), point.date.month()); month_points .entry(key) .and_modify(|(_, _, end_equity, end_benchmark)| { *end_equity = point.total_equity; *end_benchmark = point.benchmark_close; }) .or_insert(( previous_equity, previous_benchmark, point.total_equity, point.benchmark_close, )); previous_equity = point.total_equity; previous_benchmark = point.benchmark_close; } month_points .into_iter() .map( |((year, month), (start_equity, start_benchmark, end_equity, end_benchmark))| { let portfolio_return = analyzer_ratio_change(start_equity, end_equity); let benchmark_return = analyzer_ratio_change(start_benchmark, end_benchmark); AnalyzerMonthlyReturnRow { year, month, portfolio_return, benchmark_return, excess_return: portfolio_return - benchmark_return, } }, ) .collect() } pub fn analyzer_risk_summary(&self) -> AnalyzerRiskSummary { AnalyzerRiskSummary { total_return: self.metrics.total_return, annual_return: self.metrics.annual_return, benchmark_cumulative_return: self.metrics.benchmark_cumulative_return, excess_cumulative_return: self.metrics.excess_cumulative_return, alpha: self.metrics.alpha, beta: self.metrics.beta, sharpe: self.metrics.sharpe, sortino: self.metrics.sortino, information_ratio: self.metrics.information_ratio, tracking_error: self.metrics.tracking_error, volatility: self.metrics.volatility, max_drawdown: self.metrics.max_drawdown, max_drawdown_duration_days: self.metrics.max_drawdown_duration_days, win_rate: self.metrics.win_rate, excess_win_rate: self.metrics.excess_win_rate, } } } #[derive(Debug, Clone, Serialize)] pub struct BacktestDayProgress { #[serde(with = "date_format")] pub date: NaiveDate, pub cash: f64, pub market_value: f64, pub total_equity: f64, pub unit_nav: f64, pub total_return: f64, pub benchmark_close: f64, pub daily_fill_count: usize, pub cumulative_trade_count: usize, pub holding_count: usize, pub notes: String, pub diagnostics: String, pub orders: Vec, pub fills: Vec, pub holdings: Vec, pub process_events: Vec, } #[derive(Debug, Clone)] struct FuturesOpenOrder { order_id: u64, intent: FuturesOrderIntent, requested_quantity: u32, filled_quantity: u32, remaining_quantity: u32, limit_price: f64, reason: String, } pub struct BacktestEngine { data: DataSet, strategy: S, broker: BrokerSimulator, config: BacktestConfig, dividend_reinvestment: bool, cash_dividends_enabled: bool, cash_dividend_adjusts_cost_basis: bool, process_event_bus: ProcessEventBus, dynamic_universe: Option>, subscriptions: BTreeSet, futures_account: Option, next_futures_order_id: u64, futures_open_orders: Vec, futures_expirations: BTreeMap>, futures_settlement_price_mode: String, futures_cost_model: FuturesTransactionCostModel, futures_validation_config: FuturesValidationConfig, execution_quote_loader: Option, execution_quote_request_cache: BTreeSet<(NaiveDate, String, Option, Option)>, } impl BacktestEngine { pub fn new( data: DataSet, strategy: S, broker: BrokerSimulator, config: BacktestConfig, ) -> Self { Self { data, strategy, broker, config, dividend_reinvestment: false, cash_dividends_enabled: true, cash_dividend_adjusts_cost_basis: true, process_event_bus: ProcessEventBus::new(), dynamic_universe: None, subscriptions: BTreeSet::new(), futures_account: None, next_futures_order_id: 9_000_000_000, futures_open_orders: Vec::new(), futures_expirations: BTreeMap::new(), futures_settlement_price_mode: "close".to_string(), futures_cost_model: FuturesTransactionCostModel::default(), futures_validation_config: FuturesValidationConfig::default(), execution_quote_loader: None, execution_quote_request_cache: BTreeSet::new(), } } pub fn into_data(self) -> DataSet { self.data } pub fn with_execution_quote_loader(mut self, loader: F) -> Self where F: FnMut(ExecutionQuoteRequest) -> Result, BacktestError> + Send + 'static, { self.execution_quote_loader = Some(Box::new(loader)); self } pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self { self.dividend_reinvestment = enabled; self } pub fn with_cash_dividends(mut self, enabled: bool) -> Self { self.cash_dividends_enabled = enabled; self } pub fn with_cash_dividend_cost_basis_adjustment(mut self, enabled: bool) -> Self { self.cash_dividend_adjusts_cost_basis = enabled; self } pub fn with_futures_account(mut self, account: FuturesAccountState) -> Self { self.futures_account = Some(account); self } pub fn with_futures_initial_cash(self, initial_cash: f64) -> Self { self.with_futures_account(FuturesAccountState::new(initial_cash)) } pub fn futures_account(&self) -> Option<&FuturesAccountState> { self.futures_account.as_ref() } pub fn futures_account_mut(&mut self) -> Option<&mut FuturesAccountState> { self.futures_account.as_mut() } pub fn with_futures_expiration( mut self, date: NaiveDate, symbol: impl Into, settlement_price: f64, ) -> Self { self.futures_expirations .entry(date) .or_default() .insert(symbol.into(), settlement_price); self } pub fn with_futures_expirations( mut self, expirations: BTreeMap>, ) -> Self { self.futures_expirations = expirations; self } pub fn with_futures_settlement_price_mode(mut self, mode: impl Into) -> Self { self.futures_settlement_price_mode = mode.into(); self } pub fn with_futures_transaction_cost_model( mut self, cost_model: FuturesTransactionCostModel, ) -> Self { self.futures_cost_model = cost_model; self } pub fn with_futures_validation_config(mut self, config: FuturesValidationConfig) -> Self { self.futures_validation_config = config; self } pub fn process_event_bus_mut(&mut self) -> &mut ProcessEventBus { &mut self.process_event_bus } pub fn add_process_listener(&mut self, kind: ProcessEventKind, listener: F) where F: FnMut(&ProcessEvent) + 'static, { self.process_event_bus.add_listener(kind, listener); } pub fn add_any_process_listener(&mut self, listener: F) where F: FnMut(&ProcessEvent) + 'static, { self.process_event_bus.add_any_listener(listener); } pub fn install_process_mod(&mut self, module: &mut M) where M: BacktestProcessMod, { self.process_event_bus.install_mod(module); } pub fn install_process_mod_loader( &mut self, loader: &mut BacktestProcessModLoader, ) -> Vec { self.process_event_bus.install_mod_loader(loader) } pub fn install_enabled_process_mods( &mut self, loader: &mut BacktestProcessModLoader, enabled_names: &[String], ) -> Vec { self.process_event_bus .install_enabled_mods(loader, enabled_names) } } impl BacktestEngine where S: Strategy, C: CostModel, R: EquityRuleHooks, { fn ensure_execution_quotes_for_decision( &mut self, execution_date: NaiveDate, portfolio: &PortfolioState, open_orders: &[OpenOrderView], decision: &StrategyDecision, start_time: Option, end_time: Option, ) -> Result<(), BacktestError> { if self.execution_quote_loader.is_none() { return Ok(()); } if self.broker.execution_price_field() != PriceField::Last && !decision_has_algo_execution(decision) { return Ok(()); } let caller_start_time = start_time; let caller_end_time = end_time; let start_time = caller_start_time.or_else(|| self.broker.intraday_execution_start_time()); let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders); self.load_missing_execution_quotes(execution_date, start_time, end_time, &mut symbols)?; if caller_start_time.is_none() && caller_end_time.is_none() { for ((intent_start_time, intent_end_time), mut intent_symbols) in algo_execution_quote_windows_for_decision(decision, portfolio) { self.load_missing_execution_quotes( execution_date, intent_start_time, intent_end_time, &mut intent_symbols, )?; } } Ok(()) } fn load_missing_execution_quotes( &mut self, execution_date: NaiveDate, start_time: Option, end_time: Option, symbols: &mut BTreeSet, ) -> Result<(), BacktestError> { symbols.retain(|symbol| { let request_key = (execution_date, symbol.clone(), start_time, end_time); if self.execution_quote_request_cache.contains(&request_key) { return false; } if start_time.is_some() && end_time.is_none() { return !has_execution_quote_near_start_time( &self.data, execution_date, symbol, start_time.expect("checked start_time"), ); } !has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time) }); if symbols.is_empty() { return Ok(()); } let requested_symbols = symbols.iter().cloned().collect::>(); let request = ExecutionQuoteRequest { date: execution_date, start_time, end_time, symbols: std::mem::take(symbols), }; let quotes = self .execution_quote_loader .as_mut() .expect("checked execution quote loader") .as_mut()(request)?; self.data.add_execution_quotes(quotes); for symbol in requested_symbols { self.execution_quote_request_cache.insert(( execution_date, symbol, start_time, end_time, )); } Ok(()) } fn ensure_execution_quotes_for_portfolio_times( &mut self, execution_date: NaiveDate, portfolio: &PortfolioState, quote_times: &[NaiveTime], ) -> Result<(), BacktestError> { if self.execution_quote_loader.is_none() || quote_times.is_empty() { return Ok(()); } let base_symbols = portfolio .positions() .keys() .cloned() .collect::>(); if base_symbols.is_empty() { return Ok(()); } for quote_time in quote_times { let mut symbols = base_symbols.clone(); self.load_missing_execution_quotes( execution_date, Some(*quote_time), None, &mut symbols, )?; } Ok(()) } fn ensure_execution_quotes_for_symbols_at_times( &mut self, execution_date: NaiveDate, symbols: &BTreeSet, quote_times: &[NaiveTime], ) -> Result<(), BacktestError> { if self.execution_quote_loader.is_none() || quote_times.is_empty() || symbols.is_empty() { return Ok(()); } let base_symbols = symbols .iter() .filter(|symbol| !symbol.trim().is_empty()) .cloned() .collect::>(); if base_symbols.is_empty() { return Ok(()); } for quote_time in quote_times { let mut symbols = base_symbols.clone(); self.load_missing_execution_quotes( execution_date, Some(*quote_time), None, &mut symbols, )?; } Ok(()) } fn apply_strategy_directives( &mut self, execution_date: NaiveDate, decision_date: NaiveDate, decision_index: usize, 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(()); } let mut retained = Vec::with_capacity(decision.order_intents.len()); for intent in decision.order_intents.drain(..) { match intent { crate::strategy::OrderIntent::UpdateUniverse { symbols, reason } => { let symbol_count = symbols.len(); self.dynamic_universe = Some(symbols.clone()); decision .diagnostics .push(format!("dynamic_universe_updated count={symbol_count}")); publish_custom_process_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, portfolio, self.futures_account.as_ref(), open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, ProcessEvent { date: execution_date, kind: ProcessEventKind::UniverseUpdated, order_id: None, symbol: (symbol_count == 1) .then(|| symbols.iter().next().cloned()) .flatten(), side: None, detail: format!( "reason={reason} count={symbol_count} symbols={}", symbols.iter().cloned().collect::>().join(",") ), }, )?; } crate::strategy::OrderIntent::Subscribe { symbols, reason } => { let mut added = Vec::new(); for symbol in symbols { if self.subscriptions.insert(symbol.clone()) { added.push(symbol); } } if !added.is_empty() { decision.diagnostics.push(format!( "subscriptions_added count={} total={}", added.len(), self.subscriptions.len() )); publish_custom_process_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, portfolio, self.futures_account.as_ref(), open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, ProcessEvent { date: execution_date, kind: ProcessEventKind::UniverseSubscribed, order_id: None, symbol: (added.len() == 1).then(|| added[0].clone()), side: None, detail: format!( "reason={reason} count={} symbols={}", added.len(), added.join(",") ), }, )?; } } crate::strategy::OrderIntent::Unsubscribe { symbols, reason } => { let mut removed = Vec::new(); for symbol in symbols { if self.subscriptions.remove(&symbol) { removed.push(symbol); } } if !removed.is_empty() { decision.diagnostics.push(format!( "subscriptions_removed count={} total={}", removed.len(), self.subscriptions.len() )); publish_custom_process_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, portfolio, self.futures_account.as_ref(), open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, ProcessEvent { date: execution_date, kind: ProcessEventKind::UniverseUnsubscribed, order_id: None, symbol: (removed.len() == 1).then(|| removed[0].clone()), side: None, detail: format!( "reason={reason} count={} symbols={}", removed.len(), removed.join(",") ), }, )?; } } 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, self.futures_account.as_ref(), 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, self.futures_account.as_ref(), 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() ), }, )?; } crate::strategy::OrderIntent::SetManagementFeeRate { rate, reason } => { portfolio .set_management_fee_rate(rate) .map_err(BacktestError::Execution)?; decision .diagnostics .push(format!("account_management_fee_rate rate={rate:.6}")); publish_custom_process_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &*portfolio, self.futures_account.as_ref(), open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, ProcessEvent { date: execution_date, kind: ProcessEventKind::AccountManagementFee, order_id: None, symbol: None, side: None, detail: format!( "reason={reason} rate={rate:.6} management_fees={:.2}", portfolio.management_fees() ), }, )?; } crate::strategy::OrderIntent::CancelOrder { order_id, reason } => { let report = self.cancel_futures_open_order(execution_date, order_id, &reason); if report.order_events.is_empty() && report.process_events.is_empty() { retained .push(crate::strategy::OrderIntent::CancelOrder { order_id, reason }); } else { merge_futures_report(directive_report, report); } } crate::strategy::OrderIntent::CancelSymbol { symbol, reason } => { let report = self.cancel_futures_open_orders_for_symbol( execution_date, &symbol, &reason, ); if report.order_events.is_empty() && report.process_events.is_empty() { retained .push(crate::strategy::OrderIntent::CancelSymbol { symbol, reason }); } else { merge_futures_report(directive_report, report); } } crate::strategy::OrderIntent::CancelAll { reason } => { let report = self.cancel_all_futures_open_orders(execution_date, &reason); let has_stock_open_orders = !self.broker.open_order_views().is_empty(); if has_stock_open_orders || report.order_events.is_empty() { retained.push(crate::strategy::OrderIntent::CancelAll { reason: reason.clone(), }); } merge_futures_report(directive_report, report); } crate::strategy::OrderIntent::Futures { intent } => { let order_id = self.next_futures_order_id; self.next_futures_order_id += 1; let report = self.submit_futures_order(execution_date, order_id, intent, false); decision.diagnostics.push(format!( "futures_order order_id={order_id} events={}", report.order_events.len() )); merge_futures_report(directive_report, report); } other => retained.push(other), } } decision.order_intents = retained; Ok(()) } fn open_order_views(&self) -> Vec { let mut views = self.broker.open_order_views(); views.extend( self.futures_open_orders .iter() .map(|order| crate::strategy::OpenOrderView { order_id: order.order_id, symbol: order.intent.symbol.clone(), side: order.intent.side(), requested_quantity: order.requested_quantity, filled_quantity: order.filled_quantity, remaining_quantity: order.remaining_quantity, unfilled_quantity: order.remaining_quantity, status: OrderStatus::Pending, avg_price: 0.0, transaction_cost: 0.0, limit_price: order.limit_price, reason: order.reason.clone(), }), ); views.sort_by_key(|order| order.order_id); views } fn aggregate_initial_cash(&self) -> f64 { self.config.initial_cash + self .futures_account .as_ref() .map(FuturesAccountState::starting_cash) .unwrap_or(0.0) } fn aggregate_cash(&self, portfolio: &PortfolioState) -> f64 { portfolio.cash() + self .futures_account .as_ref() .map(FuturesAccountState::cash) .unwrap_or(0.0) } fn aggregate_market_value(&self, portfolio: &PortfolioState) -> f64 { portfolio.market_value() + self .futures_account .as_ref() .map(FuturesAccountState::position_equity) .unwrap_or(0.0) } fn aggregate_total_equity(&self, portfolio: &PortfolioState) -> f64 { portfolio.total_equity() + self .futures_account .as_ref() .map(FuturesAccountState::total_value) .unwrap_or(0.0) } fn submit_futures_order( &mut self, date: NaiveDate, order_id: u64, intent: FuturesOrderIntent, from_pending: bool, ) -> FuturesExecutionReport { let Some(_) = self.futures_account.as_ref() else { return self.reject_futures_order( date, order_id, intent, "futures account is not enabled".to_string(), ); }; let original_requested = intent.quantity; let mut intent = self.resolve_futures_trading_parameters(date, intent); if let Some(reason) = self.validate_futures_submission(date, &intent) { return self.reject_futures_order(date, order_id, intent, reason); } let fill = self.resolve_futures_fill(date, &intent); let Some((execution_price, fill_quantity)) = fill else { if intent.allow_pending || intent.limit_price.is_some() { return self.queue_futures_order( date, order_id, intent, original_requested, 0, from_pending, "limit not matched or no executable futures price", ); } return self.reject_futures_order( date, order_id, intent, "missing executable futures price".to_string(), ); }; if fill_quantity == 0 { if intent.allow_pending || intent.limit_price.is_some() { return self.queue_futures_order( date, order_id, intent, original_requested, 0, from_pending, "futures liquidity unavailable", ); } return self.reject_futures_order( date, order_id, intent, "futures liquidity unavailable".to_string(), ); } let remaining = original_requested.saturating_sub(fill_quantity); intent.price = execution_price; intent.quantity = fill_quantity; intent = self.resolve_futures_transaction_cost(date, intent); let mut report = self .futures_account .as_mut() .expect("checked futures account") .execute_order(date, Some(order_id), intent.clone()); if remaining > 0 && (intent.allow_pending || intent.limit_price.is_some()) { for event in &mut report.order_events { if event.order_id == Some(order_id) { event.requested_quantity = original_requested; event.filled_quantity = fill_quantity; event.status = OrderStatus::PartiallyFilled; } } let mut remaining_intent = intent.clone(); remaining_intent.quantity = remaining; remaining_intent.transaction_cost = 0.0; let queued = self.queue_futures_order( date, order_id, remaining_intent, original_requested, fill_quantity, true, "partial fill remaining quantity pending", ); report.order_events.extend(queued.order_events); report.process_events.extend(queued.process_events); report.diagnostics.extend(queued.diagnostics); } else if remaining > 0 { for event in &mut report.order_events { if event.order_id == Some(order_id) { event.requested_quantity = original_requested; event.filled_quantity = fill_quantity; event.status = OrderStatus::PartiallyFilled; event.reason.push_str(": remaining quantity canceled"); } } } report } fn process_futures_open_orders(&mut self, date: NaiveDate) -> BrokerExecutionReport { let pending = std::mem::take(&mut self.futures_open_orders); let mut combined = BrokerExecutionReport::default(); for mut order in pending { order.intent.quantity = order.remaining_quantity; let report = self.submit_futures_order(date, order.order_id, order.intent, true); merge_futures_report(&mut combined, report); } combined } fn queue_futures_order( &mut self, date: NaiveDate, order_id: u64, intent: FuturesOrderIntent, requested_quantity: u32, filled_quantity: u32, _from_pending: bool, reason: &str, ) -> FuturesExecutionReport { let mut report = FuturesExecutionReport::default(); let side = intent.side(); let limit_price = intent.limit_price.unwrap_or(intent.price); self.futures_open_orders.push(FuturesOpenOrder { order_id, requested_quantity, filled_quantity, remaining_quantity: intent.quantity, limit_price, reason: format!("{}: {reason}", intent.reason), intent, }); report.order_events.push(OrderEvent { date, order_id: Some(order_id), symbol: self .futures_open_orders .last() .map(|order| order.intent.symbol.clone()) .unwrap_or_default(), side, requested_quantity, filled_quantity, status: OrderStatus::Pending, reason: reason.to_string(), }); report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::OrderCreationPass, order_id: Some(order_id), symbol: self .futures_open_orders .last() .map(|order| order.intent.symbol.clone()), side: Some(side), detail: format!("futures pending limit_price={limit_price:.6} reason={reason}"), }); report } fn reject_futures_order( &self, date: NaiveDate, order_id: u64, intent: FuturesOrderIntent, reason: String, ) -> FuturesExecutionReport { let side = intent.side(); let mut report = FuturesExecutionReport::default(); report.order_events.push(OrderEvent { date, order_id: Some(order_id), symbol: intent.symbol.clone(), side, requested_quantity: intent.quantity, filled_quantity: 0, status: OrderStatus::Rejected, reason: format!( "{}: {reason} direction={} effect={}", intent.reason, intent.direction.as_str(), intent.effect.as_str() ), }); report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::OrderCreationReject, order_id: Some(order_id), symbol: Some(intent.symbol), side: Some(side), detail: reason, }); report } fn validate_futures_submission( &self, date: NaiveDate, intent: &FuturesOrderIntent, ) -> Option { if intent.quantity == 0 { return Some("zero futures quantity".to_string()); } if self.futures_validation_config.enforce_active_instrument { if let Some(instrument) = self.data.instrument(&intent.symbol) { if !instrument.is_active_on(date) { return Some(format!( "inactive futures instrument symbol={} date={date}", intent.symbol )); } } } if self.futures_validation_config.enforce_trading_phase { if let Some(snapshot) = self.data.market(date, &intent.symbol) { if snapshot.paused { return Some(format!( "paused futures instrument symbol={}", intent.symbol )); } if !futures_trading_phase_allows_orders(snapshot.trading_phase.as_deref()) { return Some(format!( "futures trading phase does not allow orders symbol={} phase={}", intent.symbol, snapshot.trading_phase.as_deref().unwrap_or("") )); } } } if let Some(limit_price) = intent.limit_price { if !limit_price.is_finite() || limit_price <= 0.0 { return Some("invalid futures limit price".to_string()); } if self.futures_validation_config.enforce_limit_price_tick { let tick = self.futures_price_tick(date, &intent.symbol); if !price_is_tick_aligned(limit_price, tick) { return Some(format!( "futures limit price not aligned to tick symbol={} price={limit_price:.6} tick={tick:.6}", intent.symbol )); } } if self.futures_validation_config.enforce_price_limits { if let Some(snapshot) = self.data.market(date, &intent.symbol) { if snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 && limit_price > snapshot.upper_limit + 1e-9 { return Some(format!( "futures limit price above upper limit symbol={} price={limit_price:.6} upper={:.6}", intent.symbol, snapshot.upper_limit )); } if snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 && limit_price < snapshot.lower_limit - 1e-9 { return Some(format!( "futures limit price below lower limit symbol={} price={limit_price:.6} lower={:.6}", intent.symbol, snapshot.lower_limit )); } } } for order in &self.futures_open_orders { if order.intent.symbol != intent.symbol || order.intent.side() == intent.side() { continue; } let existing_limit = order.limit_price; let crosses = match intent.side() { OrderSide::Buy => limit_price >= existing_limit, OrderSide::Sell => limit_price <= existing_limit, }; if crosses { return Some(format!( "self-trade risk with futures open order {}", order.order_id )); } } } None } fn futures_price_tick(&self, date: NaiveDate, symbol: &str) -> f64 { self.data .futures_trading_parameter(date, symbol) .map(|params| params.price_tick) .filter(|tick| tick.is_finite() && *tick > 0.0) .or_else(|| { self.data .market(date, symbol) .map(|snapshot| snapshot.effective_price_tick()) }) .unwrap_or(1.0) .max(1e-9) } fn resolve_futures_trading_parameters( &self, date: NaiveDate, mut intent: FuturesOrderIntent, ) -> FuturesOrderIntent { if let Some(params) = self.data.futures_trading_parameter(date, &intent.symbol) { intent.spec = params.spec(); } intent } fn resolve_futures_transaction_cost( &self, date: NaiveDate, mut intent: FuturesOrderIntent, ) -> FuturesOrderIntent { if intent.transaction_cost > 0.0 { return intent; } if let Some(params) = self.data.futures_trading_parameter(date, &intent.symbol) { let close_today_quantity = self.futures_close_today_quantity(&intent); intent.transaction_cost = self.futures_cost_model.calculate( params, intent.effect, intent.price, intent.quantity, close_today_quantity, ); } intent } fn futures_close_today_quantity(&self, intent: &FuturesOrderIntent) -> u32 { match intent.effect { FuturesPositionEffect::Open | FuturesPositionEffect::CloseYesterday => 0, FuturesPositionEffect::CloseToday => intent.quantity, FuturesPositionEffect::Close => self .futures_account .as_ref() .and_then(|account| account.position(&intent.symbol, intent.direction)) .map(|position| intent.quantity.saturating_sub(position.old_quantity)) .unwrap_or(0), } } fn resolve_futures_fill( &self, date: NaiveDate, intent: &FuturesOrderIntent, ) -> Option<(f64, u32)> { if self.broker.execution_price_field() == PriceField::Last { if let Some(fill) = self.resolve_futures_intraday_fill(date, intent) { return Some(fill); } } if let Some(snapshot) = self.data.market(date, &intent.symbol) { if snapshot.paused { return None; } let price = match self.broker.execution_price_field() { PriceField::DayOpen => snapshot.day_open, PriceField::Open => snapshot.open, PriceField::Close => snapshot.close, PriceField::Last => match intent.side() { OrderSide::Buy => snapshot.buy_price(PriceField::Last), OrderSide::Sell => snapshot.sell_price(PriceField::Last), }, }; if !self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) { return None; } return Some((price, intent.quantity)); } if intent.price.is_finite() && intent.price > 0.0 { if futures_limit_satisfied(intent.side(), intent.price, intent.limit_price) { return Some((intent.price, intent.quantity)); } } None } fn resolve_futures_intraday_fill( &self, date: NaiveDate, intent: &FuturesOrderIntent, ) -> Option<(f64, u32)> { let snapshot = self.data.market(date, &intent.symbol); if matches!( self.broker.matching_type(), MatchingType::MinuteBestCounterparty ) { let depth = self.data.order_book_depth_on(date, &intent.symbol); if !depth.is_empty() { return self.resolve_futures_depth_fill(date, intent, snapshot); } } let quotes = self.data.execution_quotes_on(date, &intent.symbol); for quote in quotes { let price = match self.broker.matching_type() { MatchingType::MinuteBestOwn => match intent.side() { OrderSide::Buy => { if quote.bid1.is_finite() && quote.bid1 > 0.0 { quote.bid1 } else { quote.last_price } } OrderSide::Sell => { if quote.ask1.is_finite() && quote.ask1 > 0.0 { quote.ask1 } else { quote.last_price } } }, MatchingType::MinuteBestCounterparty => match intent.side() { OrderSide::Buy => quote.buy_price().unwrap_or(quote.last_price), OrderSide::Sell => quote.sell_price().unwrap_or(quote.last_price), }, _ => quote.last_price, }; if let Some(snapshot) = snapshot { if !self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) { continue; } } else if !futures_limit_satisfied(intent.side(), price, intent.limit_price) { continue; } let top_level_quantity = match intent.side() { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, } .max(quote.volume_delta) .min(u32::MAX as u64) as u32; let fill_quantity = if top_level_quantity == 0 { intent.quantity } else { intent.quantity.min(top_level_quantity) }; if price.is_finite() && price > 0.0 && fill_quantity > 0 { return Some((price, fill_quantity)); } } None } fn resolve_futures_depth_fill( &self, date: NaiveDate, intent: &FuturesOrderIntent, snapshot: Option<&crate::data::DailyMarketSnapshot>, ) -> Option<(f64, u32)> { let depth = self.data.order_book_depth_on(date, &intent.symbol); let mut cursor = 0usize; while cursor < depth.len() { let timestamp = depth[cursor].timestamp; let start = cursor; while cursor < depth.len() && depth[cursor].timestamp == timestamp { cursor += 1; } let mut levels = depth[start..cursor].iter().collect::>(); levels.sort_by(|left, right| left.level.cmp(&right.level)); let mut filled_quantity = 0_u32; let mut gross_amount = 0.0_f64; for level in levels { let Some(price) = level.executable_price(intent.side()) else { continue; }; let can_trade = if let Some(snapshot) = snapshot { self.futures_price_can_trade(snapshot, intent.side(), price, intent.limit_price) } else { futures_limit_satisfied(intent.side(), price, intent.limit_price) }; if !can_trade { if intent.limit_price.is_some() { break; } continue; } let available_quantity = level.executable_volume(intent.side()).min(u32::MAX as u64) as u32; if available_quantity == 0 { continue; } let remaining = intent.quantity.saturating_sub(filled_quantity); if remaining == 0 { break; } let take_quantity = remaining.min(available_quantity); gross_amount += price * take_quantity as f64; filled_quantity += take_quantity; if filled_quantity >= intent.quantity { break; } } if filled_quantity > 0 { return Some((gross_amount / filled_quantity as f64, filled_quantity)); } } None } fn futures_price_can_trade( &self, snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, price: f64, limit_price: Option, ) -> bool { if !price.is_finite() || price <= 0.0 { return false; } if !futures_limit_satisfied(side, price, limit_price) { return false; } match side { OrderSide::Buy => !snapshot.is_at_upper_limit_price(price), OrderSide::Sell => !snapshot.is_at_lower_limit_price(price), } } fn cancel_futures_open_order( &mut self, date: NaiveDate, order_id: u64, reason: &str, ) -> FuturesExecutionReport { let Some(index) = self .futures_open_orders .iter() .position(|order| order.order_id == order_id) else { return FuturesExecutionReport::default(); }; let order = self.futures_open_orders.remove(index); futures_cancel_report(date, order, reason) } fn cancel_futures_open_orders_for_symbol( &mut self, date: NaiveDate, symbol: &str, reason: &str, ) -> FuturesExecutionReport { let mut report = FuturesExecutionReport::default(); let mut retained = Vec::with_capacity(self.futures_open_orders.len()); let mut canceled = Vec::new(); for order in self.futures_open_orders.drain(..) { if order.intent.symbol == symbol { canceled.push(order); } else { retained.push(order); } } self.futures_open_orders = retained; for order in canceled { merge_futures_execution_report(&mut report, futures_cancel_report(date, order, reason)); } report } fn cancel_all_futures_open_orders( &mut self, date: NaiveDate, reason: &str, ) -> FuturesExecutionReport { let mut report = FuturesExecutionReport::default(); for order in std::mem::take(&mut self.futures_open_orders) { merge_futures_execution_report(&mut report, futures_cancel_report(date, order, reason)); } report } pub fn run(&mut self) -> Result { self.run_with_progress(|_| {}) } pub fn run_with_progress( &mut self, mut on_progress: F, ) -> Result where F: FnMut(&BacktestDayProgress), { let mut portfolio = PortfolioState::new(self.config.initial_cash); let scheduler_calendar = self.data.calendar().clone(); let scheduler = Scheduler::new(&scheduler_calendar); let execution_dates = self .data .calendar() .iter() .filter(|date| { self.config .start_date .map(|start| *date >= start) .unwrap_or(true) }) .filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true)) .filter(|date| { !self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty() }) .collect::>(); let mut result = BacktestResult { strategy_name: self.strategy.name().to_string(), benchmark_series: self .data .benchmark_series() .into_iter() .filter(|row| { self.config .start_date .map(|start| row.date >= start) .unwrap_or(true) }) .filter(|row| { self.config .end_date .map(|end| row.date <= end) .unwrap_or(true) }) .collect(), risk_decisions: Vec::new(), order_events: Vec::new(), fills: Vec::new(), position_events: Vec::new(), account_events: Vec::new(), process_events: Vec::new(), equity_curve: Vec::new(), holdings_summary: Vec::new(), daily_holdings: Vec::new(), metrics: BacktestMetrics::default(), }; for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); portfolio.begin_trading_day(); if let Some(account) = self.futures_account.as_mut() { account.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 corporate_action_report = self.apply_corporate_actions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, corporate_action_report); let receivable_report = self.settle_cash_receivables( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, receivable_report); let delisting_report = self.settle_delisted_positions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, delisting_report); let futures_open_order_report = self.process_futures_open_orders(execution_date); self.extend_result(&mut result, futures_open_order_report); let decision_slot = execution_idx .checked_sub(self.config.decision_lag_trading_days) .map(|decision_idx| (decision_idx, execution_dates[decision_idx])); let Some((decision_index, decision_date)) = decision_slot else { let mut process_events = Vec::new(); let mut report = BrokerExecutionReport::default(); portfolio.update_prices_with_options( execution_date, &self.data, PriceField::Close, self.broker.same_day_buy_close_mark_at_fill(), )?; let close_report = self.broker.after_trading(execution_date); merge_broker_report(&mut report, close_report); let futures_daily_settlement_report = self.settle_futures_daily(execution_date); merge_broker_report(&mut report, futures_daily_settlement_report); let futures_expiration_report = self.settle_futures_expirations(execution_date); merge_broker_report(&mut report, futures_expiration_report); let daily_fill_count = report.fill_events.len(); let day_orders = report.order_events.clone(); let day_fills = report.fill_events.clone(); let broker_diagnostics = report.diagnostics.clone(); self.extend_result(&mut result, report); let benchmark = self.data .benchmark(execution_date) .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; let notes = corporate_action_notes.join(" | "); let diagnostics = std::iter::once(format!( "decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx )) .chain(broker_diagnostics.into_iter()) .collect::>() .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); result.equity_curve.push(DailyEquityPoint { date: execution_date, cash: aggregate_cash, market_value: aggregate_market_value, total_equity: aggregate_total_equity, benchmark_close: benchmark.close, benchmark_prev_close: benchmark.prev_close, notes, diagnostics, }); result.daily_holdings.extend(holdings_for_day.clone()); let latest = result .equity_curve .last() .expect("equity point pushed for progress event"); on_progress(&BacktestDayProgress { date: execution_date, 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 }, benchmark_close: latest.benchmark_close, daily_fill_count, cumulative_trade_count: result.fills.len(), holding_count: holdings_for_day.len(), notes: latest.notes.clone(), diagnostics: latest.diagnostics.clone(), orders: day_orders, fills: day_fills, holdings: holdings_for_day, process_events: day_process_events, }); result.process_events.append(&mut process_events); continue; }; let mut process_events = Vec::new(); let mut directive_report = BrokerExecutionReport::default(); let pre_open_orders = self.open_order_views(); let schedule_rules = self.strategy.schedule_rules(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreBeforeTrading, "before_trading:pre", )?; self.strategy.before_trading(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &pre_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::BeforeTrading), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), })?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::BeforeTrading, "before_trading", )?; let mut before_trading_decision = collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::BeforeTrading, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::BeforeTrading), result.order_events.as_slice(), result.fills.as_slice(), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, &pre_open_orders, &mut process_events, &mut before_trading_decision, &mut directive_report, )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PostBeforeTrading, "before_trading:post", )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreOpenAuction, "open_auction:pre", )?; let mut auction_decision = collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::OpenAuction, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::OpenAuction), result.order_events.as_slice(), result.fills.as_slice(), )?; auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &pre_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::OpenAuction), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), })?); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &pre_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::OpenAuction, "open_auction", )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, &pre_open_orders, &mut process_events, &mut auction_decision, &mut directive_report, )?; let pre_auction_execution_orders = self.open_order_views(); self.ensure_execution_quotes_for_decision( execution_date, &portfolio, &pre_auction_execution_orders, &auction_decision, None, None, )?; let mut report = self.broker.execute( execution_date, &mut portfolio, &self.data, &auction_decision, )?; let post_auction_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_auction_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut report.process_events, )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_auction_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PostOpenAuction, "open_auction:post", )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_auction_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreOnDay, "on_day:pre", )?; let on_day_open_orders = self.open_order_views(); let decision_quote_times = self.strategy.decision_quote_times(); if !decision_quote_times.is_empty() { let decision_quote_symbols = self.strategy.decision_quote_symbols(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &on_day_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::OnDay), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), })?; self.ensure_execution_quotes_for_symbols_at_times( execution_date, &decision_quote_symbols, &decision_quote_times, )?; } self.ensure_execution_quotes_for_portfolio_times( execution_date, &portfolio, &decision_quote_times, )?; let mut decision = decision_slot .map(|(decision_idx, decision_date)| { self.strategy.on_day(&StrategyContext { execution_date, decision_date, decision_index: decision_idx, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &on_day_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::OnDay), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), }) }) .transpose()? .unwrap_or_default(); decision.merge_from(collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::OnDay, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &on_day_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::OnDay), result.order_events.as_slice(), result.fills.as_slice(), )?); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &on_day_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::OnDay, "on_day", )?; let bar_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &bar_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreBar, "bar:pre", )?; decision.merge_from(collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::Bar, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &bar_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::Bar), result.order_events.as_slice(), result.fills.as_slice(), )?); decision.merge_from(self.strategy.on_bar(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &bar_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::Bar), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), })?); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &bar_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::Bar, "bar", )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, &on_day_open_orders, &mut process_events, &mut decision, &mut directive_report, )?; let pre_intraday_execution_orders = self.open_order_views(); self.ensure_execution_quotes_for_decision( execution_date, &portfolio, &pre_intraday_execution_orders, &decision, None, None, )?; let mut intraday_report = self.broker .execute(execution_date, &mut portfolio, &self.data, &decision)?; let post_intraday_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_intraday_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut intraday_report.process_events, )?; report.order_events.extend(intraday_report.order_events); report.fill_events.extend(intraday_report.fill_events); report .position_events .extend(intraday_report.position_events); report.account_events.extend(intraday_report.account_events); report.diagnostics.extend(intraday_report.diagnostics); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_intraday_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PostOnDay, "on_day:post", )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_intraday_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PostBar, "bar:post", )?; if should_run_minute_events(&schedule_rules, &self.subscriptions) { let filter_by_subscription = !self.subscriptions.is_empty(); let minute_quotes = self .data .execution_quotes_on_date(execution_date) .into_iter() .filter(|quote| { !filter_by_subscription || self.subscriptions.contains("e.symbol) }) .collect::>(); for quote in minute_quotes { let minute_time = quote.timestamp.time(); let minute_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &minute_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreMinute, format!("minute:{}:{}:pre", quote.symbol, quote.timestamp), )?; let mut minute_decision = collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::Minute, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &minute_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, Some(minute_time), result.order_events.as_slice(), result.fills.as_slice(), )?; minute_decision.merge_from(self.strategy.on_minute( &StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &minute_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: Some(quote.timestamp), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), }, "e, )?); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &minute_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::Minute, format!("minute:{}:{}", quote.symbol, quote.timestamp), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, &minute_open_orders, &mut process_events, &mut minute_decision, &mut directive_report, )?; let pre_minute_execution_orders = self.open_order_views(); self.ensure_execution_quotes_for_decision( execution_date, &portfolio, &pre_minute_execution_orders, &minute_decision, Some(minute_time), Some(minute_time), )?; let mut minute_report = self.broker.execute_between( execution_date, &mut portfolio, &self.data, &minute_decision, Some(minute_time), Some(minute_time), )?; let post_minute_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_minute_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut minute_report.process_events, )?; merge_broker_report(&mut report, minute_report); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_minute_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PostMinute, format!("minute:{}:{}:post", quote.symbol, quote.timestamp), )?; } } portfolio.update_prices_with_options( execution_date, &self.data, PriceField::Close, self.broker.same_day_buy_close_mark_at_fill(), )?; let post_trade_open_orders = self.open_order_views(); let visible_order_events = result .order_events .iter() .cloned() .chain(report.order_events.iter().cloned()) .collect::>(); let visible_fills = result .fills .iter() .cloned() .chain(report.fill_events.iter().cloned()) .collect::>(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_trade_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreAfterTrading, "after_trading:pre", )?; self.strategy.after_trading(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &post_trade_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::AfterTrading), ), order_events: visible_order_events.as_slice(), fills: visible_fills.as_slice(), })?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_trade_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::AfterTrading, "after_trading", )?; let mut after_trading_decision = collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::AfterTrading, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_trade_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::AfterTrading), visible_order_events.as_slice(), visible_fills.as_slice(), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &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( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_trade_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut close_report.process_events, )?; report.order_events.extend(close_report.order_events); report.fill_events.extend(close_report.fill_events); report.position_events.extend(close_report.position_events); report.account_events.extend(close_report.account_events); report.diagnostics.extend(close_report.diagnostics); let post_close_open_orders = self.open_order_views(); let visible_order_events_after_close = result .order_events .iter() .cloned() .chain(report.order_events.iter().cloned()) .collect::>(); let visible_fills_after_close = result .fills .iter() .cloned() .chain(report.fill_events.iter().cloned()) .collect::>(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_close_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PostAfterTrading, "after_trading:post", )?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_close_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::PreSettlement, "settlement:pre", )?; self.strategy.on_settlement(&StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio: &portfolio, futures_account: self.futures_account.as_ref(), open_orders: &post_close_open_orders, dynamic_universe: self.dynamic_universe.as_ref(), subscriptions: &self.subscriptions, process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::Settlement), ), order_events: visible_order_events_after_close.as_slice(), fills: visible_fills_after_close.as_slice(), })?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_close_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, ProcessEventKind::Settlement, "settlement", )?; let mut settlement_decision = collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, ScheduleStage::Settlement, &schedule_rules, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_close_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, &mut self.process_event_bus, default_stage_time(ScheduleStage::Settlement), visible_order_events_after_close.as_slice(), visible_fills_after_close.as_slice(), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, &post_close_open_orders, &mut process_events, &mut settlement_decision, &mut directive_report, )?; let futures_daily_settlement_report = self.settle_futures_daily(execution_date); merge_broker_report(&mut directive_report, futures_daily_settlement_report); let futures_expiration_report = self.settle_futures_expirations(execution_date); merge_broker_report(&mut directive_report, futures_expiration_report); let dynamic_universe_snapshot = self.dynamic_universe.clone(); let subscriptions_snapshot = self.subscriptions.clone(); let management_fee_report = self.apply_management_fee( execution_date, decision_date, decision_index, &mut portfolio, &post_close_open_orders, dynamic_universe_snapshot.as_ref(), &subscriptions_snapshot, &mut process_events, visible_order_events_after_close.as_slice(), visible_fills_after_close.as_slice(), )?; merge_broker_report(&mut directive_report, management_fee_report); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &portfolio, self.futures_account.as_ref(), &post_close_open_orders, self.dynamic_universe.as_ref(), &self.subscriptions, &mut process_events, execution_date, 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(); let broker_diagnostics = report.diagnostics.clone(); self.extend_result(&mut result, report); result.risk_decisions.extend(decision.risk_decisions); let benchmark = self.data .benchmark(execution_date) .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; let notes = corporate_action_notes .into_iter() .chain(decision.notes.into_iter()) .collect::>() .join(" | "); let diagnostics = decision .diagnostics .into_iter() .chain(broker_diagnostics.into_iter()) .collect::>() .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); result.equity_curve.push(DailyEquityPoint { date: execution_date, cash: aggregate_cash, market_value: aggregate_market_value, total_equity: aggregate_total_equity, benchmark_close: benchmark.close, benchmark_prev_close: benchmark.prev_close, notes, diagnostics, }); result.daily_holdings.extend(holdings_for_day.clone()); let latest = result .equity_curve .last() .expect("equity point pushed for progress event"); on_progress(&BacktestDayProgress { date: execution_date, 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 }, benchmark_close: latest.benchmark_close, daily_fill_count, cumulative_trade_count: result.fills.len(), holding_count: holdings_for_day.len(), notes: latest.notes.clone(), diagnostics: latest.diagnostics.clone(), orders: day_orders, fills: day_fills, holdings: holdings_for_day, process_events: day_process_events, }); result.process_events.extend(process_events); } if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } result.metrics = compute_backtest_metrics( &result.equity_curve, &result.fills, &result.daily_holdings, self.aggregate_initial_cash(), ); Ok(result) } fn extend_result( &self, result: &mut BacktestResult, report: BrokerExecutionReport, ) -> BrokerExecutionReport { result.order_events.extend(report.order_events.clone()); result.fills.extend(report.fill_events.clone()); result .position_events .extend(report.position_events.clone()); result.account_events.extend(report.account_events.clone()); result.process_events.extend(report.process_events.clone()); report } fn apply_corporate_actions( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); for action in self.data.corporate_actions_on(date) { if !action.has_effect() { continue; } let Some(existing_position) = portfolio.position(&action.symbol) else { continue; }; if existing_position.quantity == 0 { continue; } if self.cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON { let cash_before = portfolio.cash(); let (cash_delta, quantity_after, average_cost) = { let position = portfolio .position_mut_if_exists(&action.symbol) .expect("position exists for dividend action"); let cash_delta = if self.cash_dividend_adjusts_cost_basis { position.apply_cash_dividend(action.share_cash) } else { position.apply_cash_dividend_preserve_cost_basis(action.share_cash) }; (cash_delta, position.quantity, position.average_cost) }; if cash_delta.abs() > f64::EPSILON { let payable_date = action.payable_date.unwrap_or(date); portfolio.add_cash_receivable(CashReceivable { symbol: action.symbol.clone(), ex_date: date, payable_date, amount: cash_delta, reason: format!("cash_dividend {:.6}", action.share_cash), }); let note = format!( "cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}", action.symbol, action.share_cash, quantity_after, payable_date, cash_delta ); notes.push(note.clone()); report.account_events.push(AccountEvent { date, cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note, }); report.position_events.push(PositionEvent { date, symbol: action.symbol.clone(), delta_quantity: 0, quantity_after, average_cost, realized_pnl_delta: 0.0, reason: format!("cash_dividend {:.6}", action.share_cash), }); } } let split_ratio = action.split_ratio(); if (split_ratio - 1.0).abs() > f64::EPSILON { let (delta_quantity, quantity_after, average_cost) = { let position = portfolio .position_mut_if_exists(&action.symbol) .expect("position exists for split action"); let delta_quantity = position.apply_split_ratio(split_ratio); (delta_quantity, position.quantity, position.average_cost) }; if delta_quantity != 0 { let note = format!( "stock_split {} ratio={:.6} delta_qty={}", action.symbol, split_ratio, delta_quantity ); notes.push(note); report.position_events.push(PositionEvent { date, symbol: action.symbol.clone(), delta_quantity, quantity_after, average_cost, realized_pnl_delta: 0.0, reason: format!("stock_split {:.6}", split_ratio), }); } } if action.has_successor_conversion() { let successor_symbol = action .successor_symbol .as_deref() .expect("successor symbol checked"); let Some(outcome) = portfolio.apply_successor_conversion( &action.symbol, successor_symbol, action.successor_ratio_value(), action.successor_cash_value(), ) else { continue; }; let reason = format!( "successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}", outcome.old_symbol, outcome.new_symbol, action.successor_ratio_value(), action.successor_cash_value() ); notes.push(reason.clone()); report.position_events.push(PositionEvent { date, symbol: outcome.old_symbol.clone(), delta_quantity: -(outcome.old_quantity as i32), quantity_after: 0, average_cost: 0.0, realized_pnl_delta: 0.0, reason: reason.clone(), }); report.position_events.push(PositionEvent { date, symbol: outcome.new_symbol.clone(), delta_quantity: outcome.new_quantity_delta, quantity_after: outcome.new_quantity_after, average_cost: outcome.new_average_cost_after, realized_pnl_delta: 0.0, reason: reason.clone(), }); if outcome.cash_delta.abs() > f64::EPSILON { let cash_before = portfolio.cash(); portfolio.apply_cash_delta(outcome.cash_delta); report.account_events.push(AccountEvent { date, cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note: format!("{} cash={:.2}", reason, outcome.cash_delta), }); } } } portfolio.prune_flat_positions(); Ok(report) } fn settle_cash_receivables( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); let settled = portfolio.settle_cash_receivables(date); for receivable in settled { let mut note = format!( "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount ); let cash_before = portfolio.cash() - receivable.amount; if self.dividend_reinvestment && receivable.reason.starts_with("cash_dividend") && receivable.amount > 0.0 { let reinvest_price = portfolio .position(&receivable.symbol) .map(|position| position.last_price) .filter(|price| price.is_finite() && *price > 0.0) .or_else(|| { self.data .calendar() .previous_day(date) .and_then(|prev_date| { self.data.price_on_or_before( prev_date, &receivable.symbol, PriceField::Close, ) }) }); let round_lot = self .data .instrument(&receivable.symbol) .map(|instrument| instrument.round_lot.max(1)) .unwrap_or(100); if let Some(price) = reinvest_price { let raw_quantity = (receivable.amount / price).floor() as u32; let reinvest_quantity = (raw_quantity / round_lot) * round_lot; if reinvest_quantity > 0 { let reinvest_cash = reinvest_quantity as f64 * price; let residual_cash = receivable.amount - reinvest_cash; portfolio.apply_cash_delta(-reinvest_cash); portfolio.position_mut(&receivable.symbol).buy( date, reinvest_quantity, price, ); note = format!( "cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}", receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount, reinvest_quantity, price, residual_cash ); report.fill_events.push(FillEvent { date, order_id: None, symbol: receivable.symbol.clone(), side: OrderSide::Buy, quantity: reinvest_quantity, price, gross_amount: reinvest_cash, commission: 0.0, stamp_tax: 0.0, net_cash_flow: -reinvest_cash, reason: "dividend_reinvestment".to_string(), }); report.position_events.push(PositionEvent { date, symbol: receivable.symbol.clone(), delta_quantity: reinvest_quantity as i32, quantity_after: portfolio .position(&receivable.symbol) .map(|position| position.quantity) .unwrap_or(0), average_cost: portfolio .position(&receivable.symbol) .map(|position| position.average_cost) .unwrap_or(0.0), realized_pnl_delta: 0.0, reason: "dividend_reinvestment".to_string(), }); report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::Trade, order_id: None, symbol: Some(receivable.symbol.clone()), side: Some(OrderSide::Buy), detail: format!( "dividend_reinvestment quantity={} price={}", reinvest_quantity, price ), }); } } } notes.push(note.clone()); report.account_events.push(AccountEvent { date, cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note, }); } 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_futures_expirations(&mut self, date: NaiveDate) -> BrokerExecutionReport { let mut report = BrokerExecutionReport::default(); let Some(expirations) = self.futures_expirations.remove(&date) else { return report; }; let Some(account) = self.futures_account.as_mut() else { report.diagnostics.push(format!( "futures_expiration_skipped date={date} reason=no_future_account count={}", expirations.len() )); return report; }; for (symbol, settlement_price) in expirations { let futures_report = account.expire_contract(date, &symbol, settlement_price, "data_driven_expiration"); merge_futures_report(&mut report, futures_report); } report } fn settle_futures_daily(&mut self, date: NaiveDate) -> BrokerExecutionReport { let mut report = BrokerExecutionReport::default(); let Some(account) = self.futures_account.as_mut() else { return report; }; let settlement_prices = account .positions() .values() .filter_map(|position| { self.data .futures_settlement_price( date, &position.symbol, &self.futures_settlement_price_mode, ) .map(|price| (position.symbol.clone(), price)) }) .collect::>(); if settlement_prices.is_empty() { return report; } let cash_before = account.total_cash(); let cash_delta = account.settle(&settlement_prices); report.account_events.push(AccountEvent { date, cash_before, cash_after: account.total_cash(), total_equity: account.total_value(), note: format!( "futures_daily_settlement mode={} cash_delta={cash_delta:.2} symbols={}", self.futures_settlement_price_mode, settlement_prices .keys() .cloned() .collect::>() .join(",") ), }); report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::Settlement, order_id: None, symbol: None, side: None, detail: format!( "futures_daily_settlement mode={} cash_delta={cash_delta:.2} count={}", self.futures_settlement_price_mode, settlement_prices.len() ), }); report } fn apply_management_fee( &mut self, execution_date: NaiveDate, decision_date: NaiveDate, decision_index: usize, portfolio: &mut PortfolioState, open_orders: &[crate::strategy::OpenOrderView], dynamic_universe: Option<&BTreeSet>, subscriptions: &BTreeSet, process_events: &mut Vec, order_events: &[OrderEvent], fills: &[FillEvent], ) -> Result { let rate = portfolio.management_fee_rate(); if rate <= 0.0 { return Ok(BrokerExecutionReport::default()); } let fee = self .strategy .management_fee( &StrategyContext { execution_date, decision_date, decision_index, data: &self.data, portfolio, futures_account: self.futures_account.as_ref(), open_orders, dynamic_universe, subscriptions, process_events: process_events.as_slice(), active_process_event: None, active_datetime: stage_datetime( decision_date, default_stage_time(ScheduleStage::Settlement), ), order_events, fills, }, rate, )? .unwrap_or_else(|| portfolio.default_management_fee()); if fee <= 0.0 { return Ok(BrokerExecutionReport::default()); } let cash_before = portfolio.cash(); portfolio .apply_management_fee(fee) .map_err(BacktestError::Execution)?; let mut report = BrokerExecutionReport::default(); report.account_events.push(AccountEvent { date: execution_date, cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note: format!("management_fee rate={rate:.6} fee={fee:.2}"), }); publish_custom_process_event( &mut self.strategy, &mut self.process_event_bus, execution_date, decision_date, decision_index, &self.data, &*portfolio, self.futures_account.as_ref(), open_orders, dynamic_universe, subscriptions, process_events, ProcessEvent { date: execution_date, kind: ProcessEventKind::AccountManagementFee, order_id: None, symbol: None, side: None, detail: format!( "rate={rate:.6} fee={fee:.2} cash_before={cash_before:.2} cash_after={:.2} management_fees={:.2}", portfolio.cash(), portfolio.management_fees() ), }, )?; Ok(report) } fn settle_delisted_positions( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); let symbols = portfolio.positions().keys().cloned().collect::>(); for symbol in symbols { let Some(position) = portfolio.position(&symbol) else { continue; }; if position.quantity == 0 { continue; } let Some(instrument) = self.data.instrument(&symbol) else { continue; }; let should_settle = instrument.is_delisted_before(date) || (instrument.status.eq_ignore_ascii_case("delisted") && instrument.delisted_at.is_none() && self.data.market(date, &symbol).is_none()); if !should_settle { continue; } let quantity = position.quantity; let settlement_price = if position.last_price.is_finite() && position.last_price > 0.0 { position.last_price } else if position.average_cost.is_finite() && position.average_cost > 0.0 { position.average_cost } else { 0.0 }; let effective_delisted_at = instrument .delisted_at .or_else(|| self.data.calendar().previous_day(date)) .unwrap_or(date); if !settlement_price.is_finite() || settlement_price <= 0.0 { return Err(BacktestError::Execution(format!( "missing delisting settlement price for {} on {}", symbol, date ))); } let cash_before = portfolio.cash(); let gross_amount = settlement_price * quantity as f64; let realized_pnl_delta = { let position = portfolio .position_mut_if_exists(&symbol) .expect("position exists for delisting settlement"); position .sell(quantity, settlement_price) .map_err(BacktestError::Execution)? }; portfolio.apply_cash_delta(gross_amount); portfolio.prune_flat_positions(); let reason = format!( "delisted_cash_settlement effective_date={} status={}", effective_delisted_at, instrument.status ); notes.push(reason.clone()); report.order_events.push(OrderEvent { date, order_id: None, symbol: symbol.clone(), side: OrderSide::Sell, requested_quantity: quantity, filled_quantity: quantity, status: OrderStatus::Filled, reason: reason.clone(), }); report.fill_events.push(FillEvent { date, order_id: None, symbol: symbol.clone(), side: OrderSide::Sell, quantity, price: settlement_price, gross_amount, commission: 0.0, stamp_tax: 0.0, net_cash_flow: gross_amount, reason: reason.clone(), }); report.position_events.push(PositionEvent { date, symbol: symbol.clone(), delta_quantity: -(quantity as i32), quantity_after: 0, average_cost: 0.0, realized_pnl_delta, reason: reason.clone(), }); report.account_events.push(AccountEvent { date, cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note: reason, }); } Ok(report) } } fn has_execution_quote_in_window( data: &DataSet, date: NaiveDate, symbol: &str, start_time: Option, end_time: Option, ) -> bool { let start_cursor = start_time.map(|time| date.and_time(time)); let end_cursor = end_time.map(|time| date.and_time(time)); if let Some(cursor) = start_cursor && end_cursor.is_none() { return data .execution_quotes_on(date, symbol) .iter() .any(|quote| quote.timestamp <= cursor); } data.execution_quotes_on(date, symbol).iter().any(|quote| { !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) }) } fn has_execution_quote_near_start_time( data: &DataSet, date: NaiveDate, symbol: &str, start_time: NaiveTime, ) -> bool { let cursor = date.and_time(start_time); let Some(latest) = data .execution_quotes_on(date, symbol) .iter() .filter(|quote| quote.timestamp <= cursor) .max_by_key(|quote| quote.timestamp) else { return false; }; cursor.signed_duration_since(latest.timestamp) <= Duration::seconds(90) } fn decision_has_algo_execution(decision: &StrategyDecision) -> bool { decision.order_intents.iter().any(|intent| { matches!( intent, OrderIntent::AlgoValue { .. } | OrderIntent::AlgoPercent { .. } | OrderIntent::TimedTargetValue { .. } | OrderIntent::TargetPortfolioSmart { order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { .. }), .. } ) }) } fn execution_quote_symbols_for_decision( decision: &StrategyDecision, portfolio: &PortfolioState, open_orders: &[OpenOrderView], ) -> BTreeSet { let mut symbols = BTreeSet::new(); symbols.extend(open_orders.iter().map(|order| order.symbol.clone())); if decision.rebalance { symbols.extend(portfolio.positions().keys().cloned()); symbols.extend(decision.target_weights.keys().cloned()); } if !decision.exit_symbols.is_empty() { symbols.extend(decision.exit_symbols.iter().cloned()); } for intent in &decision.order_intents { match intent { OrderIntent::Shares { symbol, .. } | OrderIntent::LimitShares { symbol, .. } | OrderIntent::Lots { symbol, .. } | OrderIntent::LimitLots { symbol, .. } | OrderIntent::TargetShares { symbol, .. } | OrderIntent::LimitTargetShares { symbol, .. } | OrderIntent::TargetValue { symbol, .. } | OrderIntent::TimedTargetValue { symbol, .. } | OrderIntent::LimitTargetValue { symbol, .. } | OrderIntent::Value { symbol, .. } | OrderIntent::LimitValue { symbol, .. } | OrderIntent::Percent { symbol, .. } | OrderIntent::LimitPercent { symbol, .. } | OrderIntent::TargetPercent { symbol, .. } | OrderIntent::LimitTargetPercent { symbol, .. } | OrderIntent::AlgoValue { symbol, .. } | OrderIntent::AlgoPercent { symbol, .. } | OrderIntent::CancelSymbol { symbol, .. } => { symbols.insert(symbol.clone()); } OrderIntent::TargetPortfolioSmart { target_weights, .. } => { symbols.extend(portfolio.positions().keys().cloned()); symbols.extend(target_weights.keys().cloned()); } OrderIntent::CancelAll { .. } => { symbols.extend(open_orders.iter().map(|order| order.symbol.clone())); } OrderIntent::UpdateUniverse { .. } | OrderIntent::Subscribe { .. } | OrderIntent::Unsubscribe { .. } | OrderIntent::DepositWithdraw { .. } | OrderIntent::FinanceRepay { .. } | OrderIntent::SetManagementFeeRate { .. } | OrderIntent::CancelOrder { .. } | OrderIntent::Futures { .. } => {} } } symbols.retain(|symbol| !symbol.trim().is_empty()); symbols } fn algo_execution_quote_windows_for_decision( decision: &StrategyDecision, portfolio: &PortfolioState, ) -> BTreeMap<(Option, Option), BTreeSet> { let mut groups = BTreeMap::<(Option, Option), BTreeSet>::new(); for intent in &decision.order_intents { match intent { OrderIntent::AlgoValue { symbol, start_time, end_time, .. } | OrderIntent::AlgoPercent { symbol, start_time, end_time, .. } | OrderIntent::TimedTargetValue { symbol, start_time, end_time, .. } => { if start_time.is_some() || end_time.is_some() { groups .entry((*start_time, *end_time)) .or_default() .insert(symbol.clone()); } } OrderIntent::TargetPortfolioSmart { target_weights, order_prices: Some(TargetPortfolioOrderPricing::AlgoOrder { start_time, end_time, .. }), .. } => { if start_time.is_some() || end_time.is_some() { let symbols = groups.entry((*start_time, *end_time)).or_default(); symbols.extend(portfolio.positions().keys().cloned()); symbols.extend(target_weights.keys().cloned()); } } _ => {} } } groups } fn collect_scheduled_decisions( strategy: &mut S, scheduler: &Scheduler<'_>, execution_date: NaiveDate, stage: ScheduleStage, rules: &[ScheduleRule], decision_date: NaiveDate, decision_index: usize, data: &crate::data::DataSet, portfolio: &PortfolioState, futures_account: Option<&FuturesAccountState>, open_orders: &[crate::strategy::OpenOrderView], dynamic_universe: Option<&BTreeSet>, subscriptions: &BTreeSet, process_events: &mut Vec, process_event_bus: &mut ProcessEventBus, current_time: Option, order_events: &[OrderEvent], fills: &[FillEvent], ) -> Result { let mut combined = crate::strategy::StrategyDecision::default(); // In lagged modes such as next_bar_open, scheduled callbacks generate // signals on the decision date while the broker later matches them on the // execution date. Triggering schedules with the execution date would let a // T+1 calendar state suppress or create T-day signals. for rule in scheduler.triggered_rules_at(decision_date, stage, current_time, rules) { publish_phase_event( strategy, process_event_bus, execution_date, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events, execution_date, ProcessEventKind::PreScheduled, format!("scheduled:{}:{}:pre", rule.name, stage_label(stage)), )?; combined.merge_from(strategy.on_scheduled( &StrategyContext { execution_date, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events: process_events.as_slice(), active_process_event: None, active_datetime: stage_datetime(decision_date, current_time), order_events, fills, }, rule, )?); publish_phase_event( strategy, process_event_bus, execution_date, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events, execution_date, ProcessEventKind::PostScheduled, format!("scheduled:{}:{}:post", rule.name, stage_label(stage)), )?; } Ok(combined) } fn publish_phase_event( strategy: &mut S, process_event_bus: &mut ProcessEventBus, execution_date: NaiveDate, decision_date: NaiveDate, decision_index: usize, data: &crate::data::DataSet, portfolio: &PortfolioState, futures_account: Option<&FuturesAccountState>, open_orders: &[crate::strategy::OpenOrderView], dynamic_universe: Option<&BTreeSet>, subscriptions: &BTreeSet, events: &mut Vec, date: NaiveDate, kind: ProcessEventKind, detail: impl Into, ) -> Result<(), BacktestError> { let event = ProcessEvent { date, kind, order_id: None, symbol: None, side: None, detail: detail.into(), }; process_event_bus.publish(&event); let process_events = events.as_slice(); let event_ctx = StrategyContext { execution_date, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events, active_process_event: Some(&event), active_datetime: None, order_events: &[], fills: &[], }; strategy.on_process_event(&event_ctx, &event)?; events.push(event); Ok(()) } fn publish_process_events( strategy: &mut S, process_event_bus: &mut ProcessEventBus, execution_date: NaiveDate, decision_date: NaiveDate, decision_index: usize, data: &crate::data::DataSet, portfolio: &PortfolioState, futures_account: Option<&FuturesAccountState>, open_orders: &[crate::strategy::OpenOrderView], dynamic_universe: Option<&BTreeSet>, subscriptions: &BTreeSet, target: &mut Vec, incoming: &mut Vec, ) -> Result<(), BacktestError> { for event in incoming.drain(..) { process_event_bus.publish(&event); let process_events = target.as_slice(); let event_ctx = StrategyContext { execution_date, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events, active_process_event: Some(&event), active_datetime: None, order_events: &[], fills: &[], }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); } Ok(()) } fn publish_custom_process_event( strategy: &mut S, process_event_bus: &mut ProcessEventBus, execution_date: NaiveDate, decision_date: NaiveDate, decision_index: usize, data: &crate::data::DataSet, portfolio: &PortfolioState, futures_account: Option<&FuturesAccountState>, open_orders: &[crate::strategy::OpenOrderView], dynamic_universe: Option<&BTreeSet>, subscriptions: &BTreeSet, target: &mut Vec, event: ProcessEvent, ) -> Result<(), BacktestError> { process_event_bus.publish(&event); let process_events = target.as_slice(); let event_ctx = StrategyContext { execution_date, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events, active_process_event: Some(&event), active_datetime: None, order_events: &[], fills: &[], }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); Ok(()) } fn stage_label(stage: ScheduleStage) -> &'static str { match stage { ScheduleStage::BeforeTrading => "before_trading", ScheduleStage::OpenAuction => "open_auction", ScheduleStage::Bar => "bar", ScheduleStage::Minute => "minute", ScheduleStage::OnDay => "on_day", ScheduleStage::AfterTrading => "after_trading", ScheduleStage::Settlement => "settlement", } } fn stage_datetime( date: NaiveDate, time: Option, ) -> Option { time.map(|value| date.and_time(value)) } fn should_run_minute_events(rules: &[ScheduleRule], subscriptions: &BTreeSet) -> bool { !subscriptions.is_empty() || rules.iter().any(|rule| rule.stage == ScheduleStage::Minute) } fn merge_broker_report(target: &mut BrokerExecutionReport, incoming: BrokerExecutionReport) { target.order_events.extend(incoming.order_events); target.fill_events.extend(incoming.fill_events); target.position_events.extend(incoming.position_events); target.account_events.extend(incoming.account_events); target.process_events.extend(incoming.process_events); target.diagnostics.extend(incoming.diagnostics); } fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExecutionReport) { target.order_events.extend(incoming.order_events); target.fill_events.extend(incoming.fill_events); target.position_events.extend(incoming.position_events); target.account_events.extend(incoming.account_events); target.process_events.extend(incoming.process_events); target.diagnostics.extend(incoming.diagnostics); } fn merge_futures_execution_report( target: &mut FuturesExecutionReport, incoming: FuturesExecutionReport, ) { target.order_events.extend(incoming.order_events); target.fill_events.extend(incoming.fill_events); target.position_events.extend(incoming.position_events); target.account_events.extend(incoming.account_events); target.process_events.extend(incoming.process_events); target.diagnostics.extend(incoming.diagnostics); } fn analyzer_ratio_change(start: f64, end: f64) -> f64 { if start.abs() <= f64::EPSILON { 0.0 } else { end / start - 1.0 } } fn price_is_tick_aligned(price: f64, tick: f64) -> bool { if !price.is_finite() || !tick.is_finite() || tick <= 0.0 { return false; } let ratio = price / tick; (ratio - ratio.round()).abs() <= 1e-6 } fn futures_trading_phase_allows_orders(phase: Option<&str>) -> bool { let Some(phase) = phase.map(str::trim).filter(|value| !value.is_empty()) else { return true; }; matches!( phase.to_ascii_lowercase().as_str(), "continuous" | "trading" | "trade" | "open_auction" | "auction" | "call_auction" | "opening_auction" ) } fn futures_limit_satisfied(side: OrderSide, price: f64, limit_price: Option) -> bool { let Some(limit_price) = limit_price else { return price.is_finite() && price > 0.0; }; if !price.is_finite() || price <= 0.0 || !limit_price.is_finite() || limit_price <= 0.0 { return false; } match side { OrderSide::Buy => price <= limit_price + 1e-9, OrderSide::Sell => price + 1e-9 >= limit_price, } } fn futures_cancel_report( date: NaiveDate, order: FuturesOpenOrder, reason: &str, ) -> FuturesExecutionReport { let mut report = FuturesExecutionReport::default(); let side = order.intent.side(); report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::OrderPendingCancel, order_id: Some(order.order_id), symbol: Some(order.intent.symbol.clone()), side: Some(side), detail: format!("reason={reason}"), }); report.order_events.push(OrderEvent { date, order_id: Some(order.order_id), symbol: order.intent.symbol.clone(), side, requested_quantity: order.requested_quantity, filled_quantity: order.filled_quantity, status: OrderStatus::Canceled, reason: format!("{reason}: futures order canceled by user"), }); report.process_events.push(ProcessEvent { date, kind: ProcessEventKind::OrderCancellationPass, order_id: Some(order.order_id), symbol: Some(order.intent.symbol), side: Some(side), detail: format!( "requested_quantity={} filled_quantity={} remaining_quantity={}", order.requested_quantity, order.filled_quantity, order.remaining_quantity ), }); report } mod date_format { use chrono::NaiveDate; use serde::Serializer; const FORMAT: &str = "%Y-%m-%d"; pub fn serialize(date: &NaiveDate, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&date.format(FORMAT).to_string()) } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use chrono::NaiveDate; use super::{BacktestConfig, BacktestEngine}; use crate::broker::{BrokerSimulator, MatchingType}; use crate::cost::ChinaAShareCostModel; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField, }; use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; const SYMBOL: &str = "000001.SZ"; #[derive(Debug)] struct BuyWhenDecisionDateStrategy { decision_date: NaiveDate, } impl Strategy for BuyWhenDecisionDateStrategy { fn name(&self) -> &str { "buy_when_decision_date" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "test_buy".to_string(), }], ..StrategyDecision::default() }); } Ok(StrategyDecision::default()) } } #[derive(Debug)] struct ScheduledBuyStrategy { rule: ScheduleRule, expected_decision_date: NaiveDate, } impl Strategy for ScheduledBuyStrategy { fn name(&self) -> &str { "scheduled_buy" } fn schedule_rules(&self) -> Vec { vec![self.rule.clone()] } fn on_scheduled( &mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule, ) -> Result { assert_eq!(rule.name, self.rule.name); assert_eq!(ctx.decision_date, self.expected_decision_date); assert_eq!( ctx.current_datetime().map(|value| value.date()), Some(self.expected_decision_date) ); if ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "scheduled_test_buy".to_string(), }], ..StrategyDecision::default() }); } Ok(StrategyDecision::default()) } } #[derive(Debug)] struct ScheduledRoundTripStrategy { rule: ScheduleRule, buy_decision_date: NaiveDate, sell_decision_date: NaiveDate, } impl Strategy for ScheduledRoundTripStrategy { fn name(&self) -> &str { "scheduled_round_trip" } fn schedule_rules(&self) -> Vec { vec![self.rule.clone()] } fn on_scheduled( &mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule, ) -> Result { assert_eq!(rule.name, self.rule.name); assert_eq!( ctx.current_datetime().map(|value| value.date()), Some(ctx.decision_date) ); if ctx.decision_date == self.buy_decision_date && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "round_trip_buy".to_string(), }], ..StrategyDecision::default() }); } if ctx.decision_date == self.sell_decision_date { if let Some(position) = ctx.portfolio.position(SYMBOL) { return Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: -(position.quantity as i32), reason: "round_trip_sell".to_string(), }], ..StrategyDecision::default() }); } } Ok(StrategyDecision::default()) } } #[derive(Debug)] struct ScheduledSameDayRebuyStrategy { rule: ScheduleRule, buy_decision_date: NaiveDate, rebuy_decision_date: NaiveDate, } impl Strategy for ScheduledSameDayRebuyStrategy { fn name(&self) -> &str { "scheduled_same_day_rebuy" } fn schedule_rules(&self) -> Vec { vec![self.rule.clone()] } fn on_scheduled( &mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule, ) -> Result { assert_eq!(rule.name, self.rule.name); if ctx.decision_date == self.buy_decision_date && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "same_day_rebuy_setup_buy".to_string(), }], ..StrategyDecision::default() }); } if ctx.decision_date == self.rebuy_decision_date { if let Some(position) = ctx.portfolio.position(SYMBOL) { return Ok(StrategyDecision { order_intents: vec![ OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: -(position.quantity as i32), reason: "same_day_rebuy_sell".to_string(), }, OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "same_day_rebuy_buy".to_string(), }, ], ..StrategyDecision::default() }); } } Ok(StrategyDecision::default()) } } fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot { DailyMarketSnapshot { date, symbol: SYMBOL.to_string(), timestamp: Some(format!("{date} 15:00:00")), day_open: open, open, high: close.max(open) + 1.0, low: close.min(open) - 1.0, close, last_price: close, bid1: close - 0.01, ask1: close + 0.01, prev_close: 10.0, volume: 1_000_000, minute_volume: 10_000, bid1_volume: 10_000, ask1_volume: 10_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 200.0, lower_limit: 1.0, price_tick: 0.01, } } fn market_with_state( date: NaiveDate, open: f64, close: f64, paused: bool, upper_limit: f64, lower_limit: f64, ) -> DailyMarketSnapshot { DailyMarketSnapshot { paused, upper_limit, lower_limit, ..market(date, open, close) } } fn market_with_volume( date: NaiveDate, open: f64, close: f64, volume: u64, ) -> DailyMarketSnapshot { DailyMarketSnapshot { volume, ..market(date, open, close) } } fn factor(date: NaiveDate) -> DailyFactorSnapshot { DailyFactorSnapshot { date, symbol: SYMBOL.to_string(), market_cap_bn: 10.0, free_float_cap_bn: 8.0, pe_ttm: 12.0, turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(), } } fn candidate(date: NaiveDate) -> CandidateEligibility { CandidateEligibility { date, symbol: SYMBOL.to_string(), is_st: false, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false, risk_level_code: None, } } fn candidate_with_state( date: NaiveDate, is_paused: bool, allow_buy: bool, ) -> CandidateEligibility { CandidateEligibility { is_paused, allow_buy, ..candidate(date) } } fn candidate_with_sell_state( date: NaiveDate, is_paused: bool, allow_sell: bool, ) -> CandidateEligibility { CandidateEligibility { is_paused, allow_sell, ..candidate(date) } } fn st_candidate(date: NaiveDate) -> CandidateEligibility { CandidateEligibility { is_st: true, ..candidate(date) } } fn star_st_candidate(date: NaiveDate) -> CandidateEligibility { CandidateEligibility { is_star_st: true, ..candidate(date) } } fn one_yuan_candidate(date: NaiveDate) -> CandidateEligibility { CandidateEligibility { is_one_yuan: true, ..candidate(date) } } fn benchmark(date: NaiveDate) -> BenchmarkSnapshot { BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), open: 1000.0, close: 1000.0, prev_close: 1000.0, volume: 1_000_000, } } fn dataset() -> DataSet { let first = d(2025, 1, 2); let second = d(2025, 1, 3); dataset_with( market(first, 10.0, 11.5), market(second, 12.0, 99.0), candidate(first), candidate(second), ) } fn default_instrument() -> Instrument { Instrument { symbol: SYMBOL.to_string(), name: "Test Stock".to_string(), board: "SZ".to_string(), round_lot: 100, listed_at: Some(d(2020, 1, 1)), delisted_at: None, status: "active".to_string(), } } fn dataset_with( first_market: DailyMarketSnapshot, second_market: DailyMarketSnapshot, first_candidate: CandidateEligibility, second_candidate: CandidateEligibility, ) -> DataSet { dataset_with_instrument( default_instrument(), first_market, second_market, first_candidate, second_candidate, ) } fn dataset_with_instrument( instrument: Instrument, first_market: DailyMarketSnapshot, second_market: DailyMarketSnapshot, first_candidate: CandidateEligibility, second_candidate: CandidateEligibility, ) -> DataSet { let first = first_market.date; let second = second_market.date; DataSet::from_components( vec![instrument], vec![first_market, second_market], vec![factor(first), factor(second)], vec![first_candidate, second_candidate], vec![benchmark(first), benchmark(second)], ) .expect("dataset") } fn dataset_from_market_and_candidates( markets: Vec, candidates: Vec, ) -> DataSet { let factors = markets .iter() .map(|market| factor(market.date)) .collect::>(); let benchmarks = markets .iter() .map(|market| benchmark(market.date)) .collect::>(); DataSet::from_components( vec![default_instrument()], markets, factors, candidates, benchmarks, ) .expect("dataset") } fn run_with_matching( matching_type: MatchingType, execution_price_field: PriceField, decision_lag_trading_days: usize, ) -> super::BacktestResult { let first = d(2025, 1, 2); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, execution_price_field, ) .with_matching_type(matching_type) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(d(2025, 1, 3)), decision_lag_trading_days, execution_price_field, }; BacktestEngine::new( dataset(), BuyWhenDecisionDateStrategy { decision_date: first, }, broker, config, ) .run() .expect("backtest run") } fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult { run_scheduled_next_open_with_dataset_and_broker( dataset, scheduled_next_open_broker(FidcRiskControlConfig::default()), ) } fn scheduled_next_open_broker( risk_config: FidcRiskControlConfig, ) -> BrokerSimulator { BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open, ) .with_matching_type(MatchingType::NextBarOpen) .with_risk_config(risk_config) .with_volume_limit(false) .with_liquidity_limit(false) .with_inactive_limit(false) } fn run_scheduled_next_open_with_dataset_and_broker( dataset: DataSet, broker: BrokerSimulator, ) -> super::BacktestResult { let first = d(2025, 1, 2); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(d(2025, 1, 3)), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; BacktestEngine::new( dataset, ScheduledBuyStrategy { rule: ScheduleRule::weekly_by_weekday("weekly_signal", 4, ScheduleStage::OnDay), expected_decision_date: first, }, broker, config, ) .run() .expect("backtest run") } fn run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset: DataSet, broker: BrokerSimulator, ) -> super::BacktestResult { let first = d(2025, 1, 2); let third = d(2025, 1, 6); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(d(2025, 1, 7)), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; BacktestEngine::new( dataset, ScheduledRoundTripStrategy { rule: ScheduleRule::daily("daily_round_trip", ScheduleStage::OnDay), buy_decision_date: first, sell_decision_date: third, }, broker, config, ) .run() .expect("backtest run") } fn run_scheduled_same_day_rebuy_next_open_with_dataset_and_broker( dataset: DataSet, broker: BrokerSimulator, ) -> super::BacktestResult { let first = d(2025, 1, 2); let third = d(2025, 1, 6); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(d(2025, 1, 7)), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; BacktestEngine::new( dataset, ScheduledSameDayRebuyStrategy { rule: ScheduleRule::daily("daily_same_day_rebuy", ScheduleStage::OnDay), buy_decision_date: first, rebuy_decision_date: third, }, broker, config, ) .run() .expect("backtest run") } fn assert_next_open_canceled_with_reason(result: &super::BacktestResult, reason: &str) { let execution_date = d(2025, 1, 3); assert!(result.fills.is_empty()); assert!(result.order_events.iter().any(|event| { event.date == execution_date && matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected) && event.reason.contains(reason) })); } fn assert_round_trip_sell_canceled_with_reason(result: &super::BacktestResult, reason: &str) { let execution_date = d(2025, 1, 7); assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy)); assert!(result.fills.iter().all(|fill| fill.side != OrderSide::Sell)); assert!(result.order_events.iter().any(|event| { event.date == execution_date && event.side == OrderSide::Sell && matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected) && event.reason.contains(reason) })); } #[test] fn current_bar_close_uses_decision_day_close_for_fill() { let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, d(2025, 1, 2)); assert_eq!(result.fills[0].price, 11.5); } #[test] fn next_bar_open_skips_unavailable_lag_day_and_fills_next_open() { let result = run_with_matching(MatchingType::NextBarOpen, PriceField::Open, 1); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, d(2025, 1, 3)); assert_eq!(result.fills[0].price, 12.0); assert!( result.equity_curve[0] .diagnostics .contains("decision_lag_warmup"), "{}", result.equity_curve[0].diagnostics ); } #[test] fn next_bar_open_scheduled_signal_uses_decision_date_not_execution_date() { let result = run_scheduled_next_open_with_dataset(dataset()); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, d(2025, 1, 3)); assert_eq!(result.fills[0].price, 12.0); } #[test] fn next_bar_open_execution_risk_ignores_decision_day_paused_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market_with_state(first, 10.0, 11.5, true, 10.0, 9.0), market_with_state(second, 12.0, 99.0, false, 200.0, 1.0), candidate_with_state(first, true, false), candidate(second), )); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, second); assert_eq!(result.fills[0].price, 12.0); } #[test] fn next_bar_open_execution_risk_ignores_decision_day_upper_limit_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market_with_state(first, 10.0, 10.0, false, 10.0, 1.0), market_with_state(second, 12.0, 99.0, false, 200.0, 1.0), candidate(first), candidate(second), )); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, second); assert_eq!(result.fills[0].price, 12.0); } #[test] fn next_bar_open_execution_risk_ignores_decision_day_st_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market(second, 12.0, 99.0), st_candidate(first), candidate(second), )); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, second); assert_eq!(result.fills[0].price, 12.0); } #[test] fn next_bar_open_execution_risk_ignores_decision_day_star_st_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market(second, 12.0, 99.0), star_st_candidate(first), candidate(second), )); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, second); assert_eq!(result.fills[0].price, 12.0); } #[test] fn next_bar_open_execution_risk_rejects_execution_day_paused_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market_with_state(second, 12.0, 99.0, true, 200.0, 1.0), candidate(first), candidate_with_state(second, true, true), )); assert_next_open_canceled_with_reason(&result, "paused"); } #[test] fn next_bar_open_execution_risk_rejects_execution_day_upper_limit_buy() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market_with_state(second, 12.0, 99.0, false, 12.0, 1.0), candidate(first), candidate(second), )); assert_next_open_canceled_with_reason(&result, "open at or above upper limit"); } #[test] fn next_bar_open_execution_risk_rejects_execution_day_st_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market(second, 12.0, 99.0), candidate(first), st_candidate(second), )); assert_next_open_canceled_with_reason(&result, "st"); } #[test] fn next_bar_open_execution_risk_rejects_execution_day_star_st_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market(second, 12.0, 99.0), candidate(first), star_st_candidate(second), )); assert_next_open_canceled_with_reason(&result, "star_st"); } #[test] fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let result = run_scheduled_next_open_with_dataset(dataset_with( market(first, 10.0, 11.5), market(second, 12.0, 99.0), candidate(first), one_yuan_candidate(second), )); assert_next_open_canceled_with_reason(&result, "one_yuan"); } #[test] fn next_bar_open_execution_risk_rejects_execution_day_delisted_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let mut instrument = default_instrument(); instrument.delisted_at = Some(second); let result = run_scheduled_next_open_with_dataset(dataset_with_instrument( instrument, market(first, 10.0, 11.5), market(second, 12.0, 99.0), candidate(first), candidate(second), )); assert_next_open_canceled_with_reason(&result, "inactive_or_delisted"); } #[test] fn next_bar_open_execution_risk_rejects_blacklisted_buy_on_execution_day() { let mut risk_config = FidcRiskControlConfig::default(); risk_config .static_rules .blacklisted_symbols .insert(SYMBOL.to_string()); let result = run_scheduled_next_open_with_dataset_and_broker( dataset(), scheduled_next_open_broker(risk_config), ); assert_next_open_canceled_with_reason(&result, "blacklisted"); } #[test] fn next_bar_open_sell_risk_ignores_decision_day_paused_and_lower_limit_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset_from_market_and_candidates( vec![ market(first, 10.0, 10.5), market(second, 11.0, 11.5), market_with_state(third, 9.0, 9.0, true, 20.0, 9.0), market(fourth, 12.0, 12.2), ], vec![ candidate(first), candidate(second), candidate_with_sell_state(third, true, false), candidate(fourth), ], ), scheduled_next_open_broker(FidcRiskControlConfig::default()), ); let sell_fill = result .fills .iter() .find(|fill| fill.side == OrderSide::Sell) .expect("sell should execute on actual execution date"); assert_eq!(sell_fill.date, fourth); assert_eq!(sell_fill.price, 12.0); } #[test] fn next_bar_open_sell_risk_rejects_execution_day_lower_limit_state() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset_from_market_and_candidates( vec![ market(first, 10.0, 10.5), market(second, 11.0, 11.5), market(third, 12.0, 12.5), market_with_state(fourth, 9.0, 9.0, false, 20.0, 9.0), ], vec![ candidate(first), candidate(second), candidate(third), candidate(fourth), ], ), scheduled_next_open_broker(FidcRiskControlConfig::default()), ); assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit"); } #[test] fn next_bar_open_sell_volume_limit_ignores_decision_day_zero_volume() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) .with_volume_limit(true) .with_volume_percent(0.25); let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset_from_market_and_candidates( vec![ market(first, 10.0, 10.5), market(second, 11.0, 11.5), market_with_volume(third, 12.0, 12.5, 0), market(fourth, 12.0, 12.2), ], vec![ candidate(first), candidate(second), candidate(third), candidate(fourth), ], ), broker, ); assert!(result.fills.iter().any(|fill| fill.side == OrderSide::Buy)); assert!(result.fills.iter().any(|fill| { fill.side == OrderSide::Sell && fill.date == fourth && (fill.price - 12.0).abs() < 1e-9 })); } #[test] fn next_bar_open_sell_volume_limit_rejects_execution_day_zero_volume() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()) .with_volume_limit(true) .with_volume_percent(0.25); let result = run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset_from_market_and_candidates( vec![ market(first, 10.0, 10.5), market(second, 11.0, 11.5), market(third, 12.0, 12.5), market_with_volume(fourth, 12.0, 12.2, 0), ], vec![ candidate(first), candidate(second), candidate(third), candidate(fourth), ], ), broker, ); assert_round_trip_sell_canceled_with_reason(&result, "daily volume limit"); } #[test] fn next_bar_open_same_day_rebuy_uses_actual_execution_date() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let fourth = d(2025, 1, 7); let result = run_scheduled_same_day_rebuy_next_open_with_dataset_and_broker( dataset_from_market_and_candidates( vec![ market(first, 10.0, 10.5), market(second, 11.0, 11.5), market(third, 12.0, 12.5), market(fourth, 13.0, 13.5), ], vec![ candidate(first), candidate(second), candidate(third), candidate(fourth), ], ), scheduled_next_open_broker(FidcRiskControlConfig::default()), ); assert!(result.fills.iter().any(|fill| { fill.side == OrderSide::Buy && fill.date == second && fill.reason == "same_day_rebuy_setup_buy" })); assert!(result.fills.iter().any(|fill| { fill.side == OrderSide::Sell && fill.date == fourth && fill.reason == "same_day_rebuy_sell" })); assert!(result.order_events.iter().any(|event| { event.date == fourth && event.side == OrderSide::Buy && matches!(event.status, OrderStatus::Canceled | OrderStatus::Rejected) && event.reason.contains("same_day_rebuy_forbidden") })); assert!(!result.order_events.iter().any(|event| { event.date == third && event.reason.contains("same_day_rebuy_forbidden") })); } }