use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use serde::{Deserialize, 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, RiskFreeRateContract, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope}; 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, Copy, PartialEq, Eq)] pub enum ProcessEventRetention { /// Retain every phase and business event in the returned result. All, /// Retain only lifecycle events useful for a durable business audit. Business, /// Dispatch events to listeners and the strategy, but do not retain them /// in the returned result. None, } impl Default for ProcessEventRetention { fn default() -> Self { Self::All } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DailyEquityPoint { #[serde(with = "date_format")] pub date: NaiveDate, pub cash: f64, pub market_value: f64, pub total_equity: f64, /// External cash flow settled on this trading date (deposit positive, /// withdrawal negative). Trading cash movements are excluded. #[serde(default)] pub external_cash_flow: f64, /// Cash-flow-neutral unit NAV after all activity on this date. #[serde(default)] pub unit_nav: f64, pub benchmark_close: f64, pub benchmark_prev_close: f64, pub notes: String, 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, pub terminal_audit: BacktestTerminalAudit, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BacktestTerminalStatus { Clean, CompletedWithPendingState, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BacktestTerminalOpenOrder { pub asset_class: BacktestTerminalAssetClass, pub order_id: u64, pub symbol: String, pub side: String, pub requested_quantity: u32, pub filled_quantity: u32, pub remaining_quantity: u32, pub limit_price: f64, pub reason: String, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum BacktestTerminalAssetClass { Stock, Futures, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BacktestTerminalAudit { pub status: BacktestTerminalStatus, pub last_execution_date: Option, pub stock_open_order_count: usize, pub futures_open_order_count: usize, pub pending_cash_flow_count: usize, pub pending_cash_flow_net_amount: f64, pub cash_receivable_count: usize, pub cash_receivable_total_amount: f64, pub earliest_deferred_cash_date: Option, pub open_order_samples: Vec, pub omitted_open_order_count: usize, } impl Default for BacktestTerminalAudit { fn default() -> Self { Self { status: BacktestTerminalStatus::Clean, last_execution_date: None, stock_open_order_count: 0, futures_open_order_count: 0, pending_cash_flow_count: 0, pending_cash_flow_net_amount: 0.0, cash_receivable_count: 0, cash_receivable_total_amount: 0.0, earliest_deferred_cash_date: None, open_order_samples: Vec::new(), omitted_open_order_count: 0, } } } impl BacktestTerminalAudit { pub fn is_clean(&self) -> bool { self.status == BacktestTerminalStatus::Clean } pub fn open_order_count(&self) -> usize { self.stock_open_order_count + self.futures_open_order_count } } #[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, pub terminal_audit: BacktestTerminalAudit, } 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 + fill.transfer_fee, 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(), terminal_audit: self.terminal_audit.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 = 1.0; let mut previous_benchmark = self .equity_curve .first() .map(|point| point.benchmark_prev_close) .unwrap_or_default(); for point in &self.equity_curve { let point_nav = if point.unit_nav.is_finite() && point.unit_nav > 0.0 { point.unit_nav } else if self.metrics.initial_cash.abs() > f64::EPSILON { point.total_equity / self.metrics.initial_cash } else { 1.0 }; let key = (point.date.year(), point.date.month()); month_points .entry(key) .and_modify(|(_, _, end_equity, end_benchmark)| { *end_equity = point_nav; *end_benchmark = point.benchmark_close; }) .or_insert(( previous_equity, previous_benchmark, point_nav, point.benchmark_close, )); previous_equity = point_nav; 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, #[serde(default)] pub external_cash_flow: f64, pub unit_nav: f64, pub total_return: f64, pub benchmark_close: f64, pub daily_fill_count: usize, pub daily_order_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, process_event_retention: ProcessEventRetention, 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, preplanned_decision_quote_symbols_by_date: Option>>>, execution_quote_request_cache: BTreeSet<(NaiveDate, String, Option, Option)>, risk_free_rate_contract: Option, } fn backtest_execution_schedule( data: &DataSet, start_date: Option, end_date: Option, decision_lag_trading_days: usize, ) -> Vec<(NaiveDate, Option<(usize, NaiveDate)>)> { let calendar_dates = data .calendar() .iter() .filter(|date| start_date.map(|start| *date >= start).unwrap_or(true)) .filter(|date| end_date.map(|end| *date <= end).unwrap_or(true)) .collect::>(); let has_decision_inputs = |date: NaiveDate| { !data.factor_snapshot_rows_on(date).is_empty() && !data.candidate_snapshot_rows_on(date).is_empty() }; let has_execution_market = |date: NaiveDate| !data.market_snapshot_rows_on(date).is_empty(); let mut schedule = Vec::new(); for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() { if decision_lag_trading_days == 0 { if has_decision_inputs(execution_date) { schedule.push((execution_date, Some((calendar_idx, execution_date)))); } continue; } if !has_execution_market(execution_date) { continue; } let decision_slot = calendar_idx .checked_sub(decision_lag_trading_days) .map(|decision_idx| (decision_idx, calendar_dates[decision_idx])); match decision_slot { Some((_, decision_date)) if has_decision_inputs(decision_date) => { schedule.push((execution_date, decision_slot)); } None => schedule.push((execution_date, None)), _ => {} } } schedule } pub fn backtest_execution_dates( data: &DataSet, start_date: Option, end_date: Option, decision_lag_trading_days: usize, ) -> Vec { backtest_execution_schedule(data, start_date, end_date, decision_lag_trading_days) .into_iter() .map(|(execution_date, _)| execution_date) .collect() } 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(), process_event_retention: ProcessEventRetention::All, 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, preplanned_decision_quote_symbols_by_date: None, execution_quote_request_cache: BTreeSet::new(), risk_free_rate_contract: None, } } pub fn with_risk_free_rate_contract(mut self, contract: RiskFreeRateContract) -> Self { self.risk_free_rate_contract = Some(contract); self } 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_preplanned_decision_quote_symbols_by_date( mut self, symbols_by_date: Arc>>, ) -> Self { self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date); self } pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self { self.dividend_reinvestment = enabled; self } pub fn with_process_event_retention(mut self, retention: ProcessEventRetention) -> Self { self.process_event_retention = retention; 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, order_created_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(()); } let submission_time = start_time.or_else(|| self.broker.intraday_execution_start_time()); let post_close_window = self.broker.post_close_execution_quote_window_for_order( execution_date, order_created_date, submission_time, ); if self.broker.execution_price_field() != PriceField::Last && !decision_has_algo_execution(decision) && post_close_window.is_none() { return Ok(()); } let caller_start_time = start_time; let caller_end_time = end_time; let start_time = post_close_window .map(|window| window.0) .or(caller_start_time) .or_else(|| self.broker.intraday_execution_start_time()); let end_time = post_close_window.map(|window| window.1).or(caller_end_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_none() && end_time.is_none() { return true; } 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)?; let requested_symbol_set = requested_symbols.iter().cloned().collect::>(); if let Some(quote) = quotes.iter().find(|quote| { quote.date != execution_date || !requested_symbol_set.contains("e.symbol) }) { return Err(BacktestError::Execution(format!( "execution quote loader returned a row outside the request: requested_date={} actual_date={} symbol={}", execution_date, quote.date, quote.symbol ))); } self.data.add_execution_quotes(quotes); if start_time.is_none() && end_time.is_none() { self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?; } for symbol in requested_symbols { self.execution_quote_request_cache.insert(( execution_date, symbol, start_time, end_time, )); } Ok(()) } fn validate_full_day_execution_quote_coverage( &self, execution_date: NaiveDate, requested_symbols: &[String], ) -> Result<(), BacktestError> { let mut missing_active = Vec::new(); let mut paused_with_quotes = Vec::new(); let mut missing_daily_market = Vec::new(); for symbol in requested_symbols { let Some(_candidate) = self.data.candidate(execution_date, symbol) else { continue; }; let Some(market) = self.data.market(execution_date, symbol) else { missing_daily_market.push(symbol.clone()); continue; }; let has_quotes = !self .data .execution_quotes_on(execution_date, symbol) .is_empty(); if market.paused { if has_quotes { paused_with_quotes.push(symbol.clone()); } continue; } if market.volume > 0 && !has_quotes { missing_active.push(symbol.clone()); } } if missing_daily_market.is_empty() && missing_active.is_empty() && paused_with_quotes.is_empty() { return Ok(()); } Err(BacktestError::Execution(format!( "full-minute subscription coverage mismatch on {}: missing_daily_market={:?}, missing_active_minute_bars={:?}, paused_with_minute_bars={:?}", execution_date, missing_daily_market, missing_active, paused_with_quotes ))) } 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::ModifyOrder { order_id, new_total_quantity, new_limit_price, reason, } => retained.push(crate::strategy::OrderIntent::ModifyOrder { order_id, new_total_quantity, new_limit_price, reason, }), 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_order_views()); views.sort_by_key(|order| order.order_id); views } fn futures_open_order_views(&self) -> Vec { 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(), }) .collect() } fn terminal_audit( &self, portfolio: &PortfolioState, last_execution_date: Option, ) -> BacktestTerminalAudit { const OPEN_ORDER_SAMPLE_LIMIT: usize = 20; let stock_open_orders = self.broker.open_order_views(); let futures_open_orders = self.futures_open_order_views(); let stock_open_order_count = stock_open_orders.len(); let futures_open_order_count = futures_open_orders.len(); let open_order_count = stock_open_order_count + futures_open_order_count; let pending_cash_flow_count = portfolio.pending_cash_flows().len(); let cash_receivable_count = portfolio.cash_receivables().len(); let pending_cash_flow_net_amount = portfolio .pending_cash_flows() .iter() .map(|flow| flow.amount) .sum(); let cash_receivable_total_amount = portfolio .cash_receivables() .iter() .map(|receivable| receivable.amount) .sum(); let earliest_deferred_cash_date = portfolio .pending_cash_flows() .iter() .map(|flow| flow.payable_date) .chain( portfolio .cash_receivables() .iter() .map(|receivable| receivable.payable_date), ) .min(); let open_order_samples = stock_open_orders .iter() .map(|order| (BacktestTerminalAssetClass::Stock, order)) .chain( futures_open_orders .iter() .map(|order| (BacktestTerminalAssetClass::Futures, order)), ) .take(OPEN_ORDER_SAMPLE_LIMIT) .map(|(asset_class, order)| BacktestTerminalOpenOrder { asset_class, order_id: order.order_id, symbol: order.symbol.clone(), side: order.side.as_str().to_string(), requested_quantity: order.requested_quantity, filled_quantity: order.filled_quantity, remaining_quantity: order.remaining_quantity, limit_price: order.limit_price, reason: order.reason.clone(), }) .collect::>(); let status = if open_order_count == 0 && pending_cash_flow_count == 0 && cash_receivable_count == 0 { BacktestTerminalStatus::Clean } else { BacktestTerminalStatus::CompletedWithPendingState }; BacktestTerminalAudit { status, last_execution_date, stock_open_order_count, futures_open_order_count, pending_cash_flow_count, pending_cash_flow_net_amount, cash_receivable_count, cash_receivable_total_amount, earliest_deferred_cash_date, omitted_open_order_count: open_order_count.saturating_sub(open_order_samples.len()), open_order_samples, } } fn has_open_orders(&self) -> bool { self.broker.has_open_orders() || !self.futures_open_orders.is_empty() } 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 aggregate_unit_net_value(&self, portfolio: &PortfolioState) -> Result { if self.futures_account.is_none() { return Ok(portfolio.unit_net_value()); } if portfolio.external_cash_flow_total().abs() > 1e-9 { return Err(BacktestError::Execution( "mixed stock/futures external cash flows require an aggregate unit ledger" .to_string(), )); } let initial_cash = self.aggregate_initial_cash(); if !initial_cash.is_finite() || initial_cash <= 0.0 { return Err(BacktestError::Execution( "aggregate initial cash must be positive for stock/futures NAV".to_string(), )); } Ok(self.aggregate_total_equity(portfolio) / initial_cash) } 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, decision_date: None, order_created_date: None, execution_date: None, 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, decision_date: None, order_created_date: None, execution_date: None, 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 !intent.spec.is_resolved() { return Some(format!( "missing futures trading parameters symbol={} date={date}", intent.symbol )); } 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_options(false, false, |_| {}) } pub fn run_with_progress(&mut self, on_progress: F) -> Result where F: FnMut(&BacktestDayProgress), { self.run_with_progress_options(true, true, on_progress) } pub fn run_with_progress_options( &mut self, include_progress_details: bool, include_progress_diagnostics: bool, mut on_progress: F, ) -> Result where F: FnMut(&BacktestDayProgress), { let mut portfolio = PortfolioState::new(self.config.initial_cash); self.subscriptions = self.strategy.initial_subscriptions(); let scheduler_calendar = self.data.calendar().clone(); let scheduler = Scheduler::new(&scheduler_calendar); let execution_schedule = backtest_execution_schedule( &self.data, self.config.start_date, self.config.end_date, self.config.decision_lag_trading_days, ); let execution_dates = execution_schedule .iter() .map(|(execution_date, _)| *execution_date) .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(), terminal_audit: BacktestTerminalAudit::default(), }; let mut stock_equity_by_date = BTreeMap::::new(); let mut previous_external_cash_flow_total = portfolio.external_cash_flow_total(); for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); 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, execution_date, execution_date, ); let corporate_action_report = self.apply_corporate_actions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result( &mut result, corporate_action_report, execution_date, execution_date, ); let receivable_report = self.settle_cash_receivables( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result( &mut result, receivable_report, execution_date, execution_date, ); let delisting_report = self.audit_unresolved_delisted_positions( execution_date, &portfolio, &mut corporate_action_notes, ); self.extend_result( &mut result, delisting_report, execution_date, execution_date, ); let futures_open_order_report = self.process_futures_open_orders(execution_date); self.extend_result( &mut result, futures_open_order_report, execution_date, execution_date, ); let day_order_start = result.order_events.len(); let day_fill_start = result.fills.len(); let decision_slot = execution_schedule .get(execution_idx) .and_then(|(_, decision_slot)| *decision_slot); 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 broker_diagnostics = std::mem::take(&mut report.diagnostics); let execution_risk_decisions = risk_decisions_from_order_events(&report.order_events); self.extend_result(&mut result, report, execution_date, execution_date); result.risk_decisions.extend(execution_risk_decisions); let daily_fill_count = result.fills.len() - day_fill_start; let daily_order_count = result.order_events.len() - day_order_start; let benchmark = self.data .benchmark(execution_date) .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; let notes = join_text_parts(corporate_action_notes.into_iter()); let diagnostics = join_text_parts( std::iter::once(format!( "decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx )) .chain(broker_diagnostics.into_iter()), ); let holdings_for_day = portfolio.holdings_summary(execution_date); let holding_start = result.daily_holdings.len(); let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); let progress_process_start = result.process_events.len(); self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_total_equity = self.aggregate_total_equity(&portfolio); let unit_nav = self.aggregate_unit_net_value(&portfolio)?; let external_cash_flow = portfolio.external_cash_flow_total() - previous_external_cash_flow_total; previous_external_cash_flow_total = portfolio.external_cash_flow_total(); result.equity_curve.push(DailyEquityPoint { date: execution_date, cash: aggregate_cash, market_value: aggregate_market_value, total_equity: aggregate_total_equity, external_cash_flow, unit_nav, benchmark_close: benchmark.close, benchmark_prev_close: benchmark.prev_close, notes, diagnostics, }); 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, external_cash_flow: latest.external_cash_flow, unit_nav: latest.unit_nav, total_return: latest.unit_nav - 1.0, benchmark_close: latest.benchmark_close, daily_fill_count, daily_order_count, cumulative_trade_count: result.fills.len(), holding_count, notes: include_progress_diagnostics .then(|| latest.notes.clone()) .unwrap_or_default(), diagnostics: include_progress_diagnostics .then(|| latest.diagnostics.clone()) .unwrap_or_default(), orders: include_progress_details .then(|| result.order_events[day_order_start..].to_vec()) .unwrap_or_default(), fills: include_progress_details .then(|| result.fills[day_fill_start..].to_vec()) .unwrap_or_default(), holdings: include_progress_details .then(|| result.daily_holdings[holding_start..].to_vec()) .unwrap_or_default(), process_events: include_progress_details .then(|| result.process_events[progress_process_start..].to_vec()) .unwrap_or_default(), }); stock_equity_by_date.insert(execution_date, portfolio.total_equity()); continue; }; let decision_total_equity = (decision_date < execution_date) .then(|| stock_equity_by_date.get(&decision_date).copied()) .flatten(); 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(); let keep_timed_rules_on_coarse_stage = self.broker.matching_type() == MatchingType::NextBarOpen; let coarse_schedule_rules = schedule_rules .iter() .filter(|rule| { keep_timed_rules_on_coarse_stage || !is_on_day_or_bar_physical_time_rule(rule) }) .cloned() .collect::>(); let intraday_schedule_rules = schedule_rules .iter() .filter(|rule| { rule.stage == ScheduleStage::Minute || (!keep_timed_rules_on_coarse_stage && is_on_day_or_bar_physical_time_rule(rule)) }) .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(), &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_for_stage( &mut self.strategy, &scheduler, execution_date, ScheduleStage::BeforeTrading, &coarse_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, 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_for_stage( &mut self.strategy, &scheduler, execution_date, ScheduleStage::OpenAuction, &coarse_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, 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, decision_date, &portfolio, &pre_auction_execution_orders, &auction_decision, None, None, )?; let mut report = self.broker.execute_with_event_dates_and_decision_equity( execution_date, decision_date, decision_date, decision_total_equity, &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 self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() { if let Some(preplanned) = self .preplanned_decision_quote_symbols_by_date .as_ref() .map(Arc::clone) { let empty_symbols = BTreeSet::new(); let decision_quote_symbols = preplanned .get(&execution_date) .unwrap_or(&empty_symbols); self.ensure_execution_quotes_for_symbols_at_times( execution_date, decision_quote_symbols, &decision_quote_times, )?; } else { 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_for_stage( &mut self.strategy, &scheduler, execution_date, ScheduleStage::OnDay, &coarse_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, 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_for_stage( &mut self.strategy, &scheduler, execution_date, ScheduleStage::Bar, &coarse_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, 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, decision_date, &portfolio, &pre_intraday_execution_orders, &decision, None, None, )?; let mut intraday_report = self.broker.execute_with_event_dates_and_decision_equity( execution_date, decision_date, decision_date, decision_total_equity, &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(&intraday_schedule_rules, &self.subscriptions) { if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() { let mut minute_symbols = self.subscriptions.clone(); self.load_missing_execution_quotes( execution_date, None, None, &mut minute_symbols, )?; } // Keep the iterator attached to an O(1) DataSet clone. This // preserves the immutable quote snapshot for the day while // allowing lazy quote loads and broker state updates on self. let quote_data = self.data.clone(); let mut minute_quotes = quote_data .execution_quotes_iter_on_date_for_symbols( execution_date, (!self.subscriptions.is_empty()).then_some(&self.subscriptions), ) .peekable(); let requires_minute_callbacks = self.strategy.requires_minute_callbacks(); let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[ ProcessEventKind::PreMinute, ProcessEventKind::Minute, ProcessEventKind::PostMinute, ]); let minute_all_time_rules = intraday_schedule_rules .iter() .filter(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()) .cloned() .collect::>(); let minute_schedule_all_times = !minute_all_time_rules.is_empty(); let mut minute_schedule_timestamps = intraday_schedule_rules .iter() .filter(|rule| scheduler.is_due_on(decision_date, rule)) .filter_map(|rule| { let minute = rule.time_rule.as_ref()?.minute_of_day()?; NaiveTime::from_hms_opt(minute / 60, minute % 60, 0) }) .map(|time| execution_date.and_time(time)) .collect::>() .into_iter() .peekable(); let mut minute_group = Vec::new(); // Merge the immutable quote stream with clock events. Equal // timestamps form one event; scheduled callbacks run before // `on_minute` below. loop { let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp); let next_schedule_timestamp = minute_schedule_timestamps.peek().copied(); let Some(minute_timestamp) = next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp) else { break; }; let minute_time = minute_timestamp.time(); minute_group.clear(); while minute_quotes .peek() .is_some_and(|quote| quote.timestamp == minute_timestamp) { minute_group.push( minute_quotes .next() .expect("peeked minute quote must be available"), ); } let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp); if has_specific_schedule { minute_schedule_timestamps.next(); } let schedule_candidate = has_specific_schedule || (minute_schedule_all_times && !minute_group.is_empty()); if !requires_minute_callbacks && !has_minute_process_listeners && !schedule_candidate && !self.has_open_orders() { continue; } 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:{minute_timestamp}:pre"), )?; let mut minute_decision = if schedule_candidate { let event_rules = if has_specific_schedule { intraday_schedule_rules.as_slice() } else { minute_all_time_rules.as_slice() }; let mut scheduled = StrategyDecision::default(); for stage in [ ScheduleStage::OnDay, ScheduleStage::Bar, ScheduleStage::Minute, ] { scheduled.merge_from(collect_scheduled_decisions( &mut self.strategy, &scheduler, execution_date, stage, event_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(), )?); } scheduled } else { crate::strategy::StrategyDecision::default() }; if requires_minute_callbacks { for "e in &minute_group { 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(minute_timestamp), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), }, quote, )?); } } 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:{minute_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, decision_date, &portfolio, &pre_minute_execution_orders, &minute_decision, Some(minute_time), Some(minute_time), )?; let mut minute_report = self .broker .execute_between_with_event_dates_and_decision_equity( execution_date, decision_date, decision_date, decision_total_equity, &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); decision.notes.append(&mut minute_decision.notes); decision .diagnostics .append(&mut minute_decision.diagnostics); decision .risk_decisions .append(&mut minute_decision.risk_decisions); 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:{minute_timestamp}:post"), )?; } drop(minute_group); drop(minute_quotes); drop(quote_data); self.data.release_execution_quotes_on_date(execution_date); } 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 mut broker_diagnostics = std::mem::take(&mut report.diagnostics); self.extend_result( &mut result, std::mem::take(&mut report), decision_date, execution_date, ); 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: 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(), &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_for_stage( &mut self.strategy, &scheduler, execution_date, ScheduleStage::AfterTrading, &coarse_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, result.order_events.as_slice(), result.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(); broker_diagnostics.append(&mut report.diagnostics); self.extend_result( &mut result, std::mem::take(&mut report), decision_date, execution_date, ); 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: 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(), &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_for_stage( &mut self.strategy, &scheduler, execution_date, ScheduleStage::Settlement, &coarse_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, result.order_events.as_slice(), result.fills.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 management_fee_report = if portfolio.management_fee_rate() <= 0.0 { BrokerExecutionReport::default() } else { // The strategy context needs an immutable view while the // engine mutably invokes the strategy. Avoid cloning these // potentially large sets unless management fees are enabled. let dynamic_universe_snapshot = self.dynamic_universe.clone(); let subscriptions_snapshot = self.subscriptions.clone(); 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, result.order_events.as_slice(), result.fills.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); broker_diagnostics.append(&mut report.diagnostics); self.extend_result( &mut result, std::mem::take(&mut report), decision_date, execution_date, ); let daily_fill_count = result.fills.len() - day_fill_start; let daily_order_count = result.order_events.len() - day_order_start; let execution_risk_decisions = risk_decisions_from_order_events(&result.order_events[day_order_start..]); result.risk_decisions.extend(decision.risk_decisions); result.risk_decisions.extend(execution_risk_decisions); let benchmark = self.data .benchmark(execution_date) .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; let notes = join_text_parts( corporate_action_notes .into_iter() .chain(decision.notes.into_iter()), ); let diagnostics = join_text_parts( decision .diagnostics .into_iter() .chain(broker_diagnostics.into_iter()), ); let holdings_for_day = portfolio.holdings_summary(execution_date); let holding_start = result.daily_holdings.len(); let holding_count = holdings_for_day.len(); result.daily_holdings.extend(holdings_for_day); let progress_process_start = result.process_events.len(); self.retain_process_events(&mut result.process_events, &mut process_events); let aggregate_cash = self.aggregate_cash(&portfolio); let aggregate_market_value = self.aggregate_market_value(&portfolio); let aggregate_total_equity = self.aggregate_total_equity(&portfolio); let unit_nav = self.aggregate_unit_net_value(&portfolio)?; let external_cash_flow = portfolio.external_cash_flow_total() - previous_external_cash_flow_total; previous_external_cash_flow_total = portfolio.external_cash_flow_total(); result.equity_curve.push(DailyEquityPoint { date: execution_date, cash: aggregate_cash, market_value: aggregate_market_value, total_equity: aggregate_total_equity, external_cash_flow, unit_nav, benchmark_close: benchmark.close, benchmark_prev_close: benchmark.prev_close, notes, diagnostics, }); 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, external_cash_flow: latest.external_cash_flow, unit_nav: latest.unit_nav, total_return: latest.unit_nav - 1.0, benchmark_close: latest.benchmark_close, daily_fill_count, daily_order_count, cumulative_trade_count: result.fills.len(), holding_count, notes: include_progress_diagnostics .then(|| latest.notes.clone()) .unwrap_or_default(), diagnostics: include_progress_diagnostics .then(|| latest.diagnostics.clone()) .unwrap_or_default(), orders: include_progress_details .then(|| result.order_events[day_order_start..].to_vec()) .unwrap_or_default(), fills: include_progress_details .then(|| result.fills[day_fill_start..].to_vec()) .unwrap_or_default(), holdings: include_progress_details .then(|| result.daily_holdings[holding_start..].to_vec()) .unwrap_or_default(), process_events: include_progress_details .then(|| result.process_events[progress_process_start..].to_vec()) .unwrap_or_default(), }); stock_equity_by_date.insert(execution_date, portfolio.total_equity()); } if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied()); result.metrics = compute_backtest_metrics( &result.equity_curve, &result.fills, &result.daily_holdings, &result.account_events, self.aggregate_initial_cash(), self.risk_free_rate_contract.as_ref(), ) .map_err(BacktestError::Execution)?; Ok(result) } fn extend_result( &self, result: &mut BacktestResult, mut report: BrokerExecutionReport, decision_date: NaiveDate, execution_date: NaiveDate, ) { annotate_broker_report_dates(&mut report, decision_date, decision_date, execution_date); result.order_events.append(&mut report.order_events); result.fills.append(&mut report.fill_events); result.position_events.append(&mut report.position_events); result.account_events.append(&mut report.account_events); self.retain_process_events(&mut result.process_events, &mut report.process_events); } fn retain_process_events( &self, target: &mut Vec, incoming: &mut Vec, ) { match self.process_event_retention { ProcessEventRetention::All => target.append(incoming), ProcessEventRetention::Business => target.extend( incoming .drain(..) .filter(|event| event.kind.is_business_lifecycle()), ), ProcessEventRetention::None => incoming.clear(), } } 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) .map_err(BacktestError::Execution)?; 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 due = portfolio.take_due_cash_receivables(date); for receivable in due { let cash_before = portfolio.cash(); portfolio .settle_cash_receivable(&receivable) .map_err(BacktestError::Execution)?; let mut note = format!( "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", receivable.symbol, receivable.ex_date, receivable.payable_date, 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) .map_err(BacktestError::Execution)?; 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, decision_date: None, order_created_date: None, execution_date: None, execution_start_timestamp: None, execution_timestamp: None, 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, transfer_fee: 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, ) -> Result { let mut report = BrokerExecutionReport::default(); for flow in portfolio .settle_pending_cash_flows(date) .map_err(BacktestError::Execution)? { let cash_before = portfolio.cash() - flow.amount; let note = format!( "deposit_withdraw_settled amount={:.2} payable_date={} reason={}", 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, }); } Ok(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 audit_unresolved_delisted_positions( &self, date: NaiveDate, portfolio: &PortfolioState, notes: &mut Vec, ) -> BrokerExecutionReport { let 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 is_unresolved = instrument.is_delisted_on_or_before(date) || (instrument.status.eq_ignore_ascii_case("delisted") && instrument.delisted_at.is_none() && self.data.market(date, &symbol).is_none()); if !is_unresolved { continue; } let effective_delisted_at = instrument .delisted_at .or_else(|| self.data.calendar().previous_day(date)) .unwrap_or(date); let reason = format!( concat!( "unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ", "settlement_action=missing valuation_policy=zero no_order=true" ), symbol, position.quantity, effective_delisted_at, instrument.status ); if instrument.delisted_at == Some(date) || instrument.delisted_at.is_none() { notes.push(reason.clone()); } } 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.unwrapped(), 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.unwrapped() { 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::ModifyOrder { order_id, .. } => { if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id) { symbols.insert(order.symbol.clone()); } } OrderIntent::WithTimeInForce { .. } => unreachable!("intent is unwrapped"), 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.unwrapped() { 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) { let distinct_timed_minute_count = rules .iter() .filter(|candidate| candidate.stage == stage && candidate.name == rule.name) .filter_map(|candidate| candidate.time_rule.as_ref()?.minute_of_day()) .collect::>() .len(); 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, scheduled_event_detail( rule, stage, current_time, distinct_timed_minute_count, "pre", ), )?; 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, scheduled_event_detail( rule, stage, current_time, distinct_timed_minute_count, "post", ), )?; } Ok(combined) } fn scheduled_event_detail( rule: &ScheduleRule, stage: ScheduleStage, current_time: Option, distinct_timed_minute_count: usize, phase: &str, ) -> String { if distinct_timed_minute_count > 1 && rule.time_rule.is_some() && let Some(time) = current_time { return format!( "scheduled:{}:{}:{}:{phase}", rule.name, stage_label(stage), time.format("%H:%M") ); } format!("scheduled:{}:{}:{phase}", rule.name, stage_label(stage)) } fn collect_scheduled_decisions_for_stage( 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, order_events: &[OrderEvent], fills: &[FillEvent], ) -> Result { let mut times = BTreeSet::new(); for rule in rules.iter().filter(|rule| rule.stage == stage) { let time = match rule.time_rule.as_ref() { Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(value)) => { let hour = value / 60; let minute = value % 60; Some(NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| { BacktestError::Execution(format!( "invalid schedule minute-of-day {} for rule {}", value, rule.name )) })?) } Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) | None => { default_stage_time(stage) } }; times.insert(time); } let mut combined = crate::strategy::StrategyDecision::default(); for time in times { combined.merge_from(collect_scheduled_decisions( strategy, scheduler, execution_date, stage, rules, decision_date, decision_index, data, portfolio, futures_account, open_orders, dynamic_universe, subscriptions, process_events, process_event_bus, time, order_events, fills, )?); } 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 next_minute_event_timestamp( quote_timestamp: Option, schedule_timestamp: Option, ) -> Option { match (quote_timestamp, schedule_timestamp) { (Some(quote), Some(schedule)) => Some(quote.min(schedule)), (Some(quote), None) => Some(quote), (None, Some(schedule)) => Some(schedule), (None, None) => None, } } fn is_on_day_or_bar_physical_time_rule(rule: &ScheduleRule) -> bool { matches!(rule.stage, ScheduleStage::OnDay | ScheduleStage::Bar) && matches!( rule.time_rule, Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(_)) ) } fn should_run_minute_events(rules: &[ScheduleRule], subscriptions: &BTreeSet) -> bool { !subscriptions.is_empty() || rules.iter().any(|rule| { rule.stage == ScheduleStage::Minute || is_on_day_or_bar_physical_time_rule(rule) }) } 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 annotate_broker_report_dates( report: &mut BrokerExecutionReport, decision_date: NaiveDate, order_created_date: NaiveDate, execution_date: NaiveDate, ) { for event in &mut report.order_events { event.decision_date.get_or_insert(decision_date); event.order_created_date.get_or_insert(order_created_date); event.execution_date.get_or_insert(execution_date); } for fill in &mut report.fill_events { fill.decision_date.get_or_insert(decision_date); fill.order_created_date.get_or_insert(order_created_date); fill.execution_date.get_or_insert(execution_date); } } fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec { order_events .iter() .filter_map(risk_decision_from_order_event) .collect() } fn risk_decision_from_order_event(order_event: &OrderEvent) -> Option { if !matches!( order_event.status, OrderStatus::Canceled | OrderStatus::Rejected ) { return None; } let rule_code = execution_risk_rule_code(&order_event.reason)?; Some(FidcRiskDecisionAudit { date: order_event.date, symbol: order_event.symbol.clone(), scope: match order_event.side { OrderSide::Buy => RiskCheckScope::Buy, OrderSide::Sell => RiskCheckScope::Sell, }, stage: "execution".to_string(), accepted: false, rule_code: rule_code.to_string(), reason: order_event.reason.clone(), config_version: Some("inline_risk_policy".to_string()), data_epoch: order_event.date.to_string(), selection_batch_id: None, order_id: order_event.order_id.map(|id| id.to_string()), }) } fn execution_risk_rule_code(reason: &str) -> Option<&'static str> { let normalized = normalize_risk_reason(reason); let tokens = normalized .split('_') .filter(|token| !token.is_empty()) .collect::>(); if normalized.contains("same_day_rebuy") || normalized.contains("sell_then_rebuy") { return Some("same_day_rebuy"); } if normalized.contains("blacklist") { return Some("blacklisted"); } if normalized.contains("star_st") || normalized.contains("st_star") { return Some("star_st"); } if tokens.contains("st") { return Some("st"); } if normalized.contains("upper_limit") || normalized.contains("limit_up") || normalized.contains("above_upper") { return Some("upper_limit"); } if normalized.contains("lower_limit") || normalized.contains("limit_down") || normalized.contains("below_lower") { return Some("lower_limit"); } if normalized.contains("paused") || normalized.contains("suspended") || normalized.contains("suspension") { return Some("paused"); } if normalized.contains("inactive") || normalized.contains("delisted") || normalized.contains("not_listed") { return Some("inactive_or_delisted"); } if tokens.contains("kcb") || normalized.contains("science_technology_board") { return Some("kcb"); } if tokens.contains("bjse") || normalized.contains("beijing_stock_exchange") { return Some("bjse"); } if normalized.contains("one_yuan") { return Some("one_yuan"); } if normalized.contains("buy_disabled") || normalized.contains("allow_buy") { return Some("buy_disabled"); } if normalized.contains("sell_disabled") || normalized.contains("allow_sell") { return Some("sell_disabled"); } if normalized.contains("trade_disabled") { return Some("trade_disabled"); } if normalized.contains("no_volume") || normalized.contains("volume_limit") { return Some("volume_limit"); } if normalized.contains("liquidity") || normalized.contains("level1") { return Some("liquidity_limit"); } if normalized.contains("no_market_data") || normalized.contains("market_data_unavailable") || normalized.contains("no_executable_price") || normalized.contains("missing_price") { return Some("market_data_unavailable"); } if normalized.contains("sellable_quantity") || normalized.contains("no_sellable_quantity") { return Some("sellable_quantity"); } None } fn normalize_risk_reason(reason: &str) -> String { reason .to_ascii_lowercase() .chars() .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) .collect() } 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 join_text_parts(parts: I) -> String where I: IntoIterator, { let mut iterator = parts.into_iter(); let Some(first) = iterator.next() else { return String::new(); }; let mut result = first; for part in iterator { result.push_str(" | "); result.push_str(&part); } result } 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, decision_date: None, order_created_date: None, execution_date: None, 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::{Deserialize, Deserializer, 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()) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let value = String::deserialize(deserializer)?; NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom) } } #[cfg(test)] mod tests { use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use std::sync::{Arc, Mutex}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use super::{BacktestConfig, BacktestEngine}; use crate::broker::{BrokerSimulator, MatchingType, SlippageModel}; use crate::cost::ChinaAShareCostModel; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceField, }; use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; use crate::risk_control::{FidcRiskControlConfig, RiskCheckScope}; use crate::rules::ChinaEquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; const SYMBOL: &str = "000001.SZ"; #[test] fn join_text_parts_matches_vec_join_contract() { assert_eq!(super::join_text_parts(Vec::::new()), ""); assert_eq!( super::join_text_parts(vec!["a".to_string(), "".to_string(), "c".to_string()]), "a | | c" ); } #[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 MinuteScheduleProbeStrategy { rules: Vec, observed: Rc>>, } impl Strategy for MinuteScheduleProbeStrategy { fn name(&self) -> &str { "minute_schedule_probe" } fn schedule_rules(&self) -> Vec { self.rules.clone() } fn on_scheduled( &mut self, ctx: &StrategyContext<'_>, _rule: &ScheduleRule, ) -> Result { self.observed .borrow_mut() .push(ctx.current_datetime().expect("scheduled event time")); Ok(StrategyDecision::default()) } } #[derive(Debug)] struct TimedOnDayBuyStrategy { rules: Vec, } impl Strategy for TimedOnDayBuyStrategy { fn name(&self) -> &str { "timed_on_day_buy" } fn schedule_rules(&self) -> Vec { self.rules.clone() } fn on_scheduled( &mut self, _ctx: &StrategyContext<'_>, rule: &ScheduleRule, ) -> Result { Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: rule.name.clone(), }], notes: vec![format!("note:{}", rule.name)], diagnostics: vec![format!("diagnostic:{}", rule.name)], ..StrategyDecision::default() }) } } #[derive(Debug)] struct ScheduledBuyOnDecisionDateStrategy { rule: ScheduleRule, decision_date: NaiveDate, } impl Strategy for ScheduledBuyOnDecisionDateStrategy { fn name(&self) -> &str { "scheduled_buy_on_decision_date" } 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.decision_date { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "scheduled_decision_date_buy".to_string(), }], ..StrategyDecision::default() }) } } #[derive(Debug)] struct ScheduledTargetPortfolioSmartStrategy { rule: ScheduleRule, decision_date: NaiveDate, target_weights: BTreeMap, } impl Strategy for ScheduledTargetPortfolioSmartStrategy { fn name(&self) -> &str { "scheduled_target_portfolio_smart" } 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.decision_date { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { order_intents: vec![OrderIntent::TargetPortfolioSmart { target_weights: self.target_weights.clone(), order_prices: None, valuation_prices: None, reason: "scheduled_target_portfolio_smart".to_string(), }], ..StrategyDecision::default() }) } } #[derive(Debug)] struct ScheduledTargetPercentStrategy { first_decision_date: NaiveDate, second_decision_date: NaiveDate, } impl Strategy for ScheduledTargetPercentStrategy { fn name(&self) -> &str { "scheduled_target_percent" } fn on_day( &mut self, ctx: &StrategyContext<'_>, ) -> Result { let order_intents = if ctx.decision_date == self.first_decision_date { vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 1_000, reason: "initial_position".to_string(), }] } else if ctx.decision_date == self.second_decision_date { vec![OrderIntent::TargetPercent { symbol: SYMBOL.to_string(), target_percent: 0.5, reason: "frozen_target_percent".to_string(), }] } else { Vec::new() }; Ok(StrategyDecision { order_intents, ..StrategyDecision::default() }) } } #[derive(Debug)] struct ScheduledEligibleUniverseBuyStrategy { rule: ScheduleRule, expected_decision_date: NaiveDate, } impl Strategy for ScheduledEligibleUniverseBuyStrategy { fn name(&self) -> &str { "scheduled_eligible_universe_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); let Some(symbol) = ctx .eligible_universe_on(ctx.decision_date) .first() .map(|row| row.symbol.clone()) else { return Ok(StrategyDecision::default()); }; Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol, quantity: 100, reason: "eligible_universe_next_open_buy".to_string(), }], ..StrategyDecision::default() }) } } #[derive(Debug)] struct ScheduledDataDateProbeStrategy { rule: ScheduleRule, expected_decision_date: NaiveDate, observed: Rc>>, } impl Strategy for ScheduledDataDateProbeStrategy { fn name(&self) -> &str { "scheduled_data_date_probe" } 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.expected_decision_date { return Ok(StrategyDecision::default()); } let current_close = ctx .current_snapshot(SYMBOL) .map(|snapshot| format!("{:.2}", snapshot.close)) .unwrap_or_default(); let history_close = ctx .history_bars(SYMBOL, 2, "1d", "close", true) .iter() .map(|value| format!("{value:.2}")) .collect::>() .join(","); let suspended = ctx .is_suspended(SYMBOL, 1) .into_iter() .map(|value| if value { "1" } else { "0" }) .collect::>() .join(","); self.observed.borrow_mut().push(format!( "signal={};execution={};active={};current={current_close};history={history_close};suspended={suspended}", ctx.signal_date(), ctx.execution_trade_date(), ctx.current_datetime() .map(|datetime| datetime.date().to_string()) .unwrap_or_default() )); Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, reason: "data_date_probe_buy".to_string(), }], ..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), adjustment_factor_backward1: None, 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") } #[test] fn backtest_execution_dates_match_sparse_lagged_equity_schedule() { let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6), d(2025, 1, 7)]; let data = DataSet::from_components( vec![default_instrument()], dates.iter().map(|date| market(*date, 10.0, 10.0)).collect(), vec![factor(dates[0]), factor(dates[2])], vec![candidate(dates[0]), candidate(dates[2])], dates.iter().map(|date| benchmark(*date)).collect(), ) .expect("sparse lagged dataset"); assert_eq!( super::backtest_execution_dates(&data, Some(dates[0]), Some(dates[3]), 1,), vec![dates[0], dates[1], dates[3]] ); assert_eq!( super::backtest_execution_dates(&data, Some(dates[0]), Some(dates[3]), 0,), vec![dates[0], dates[2]] ); } fn engine_with_matching( matching_type: MatchingType, execution_price_field: PriceField, decision_lag_trading_days: usize, ) -> BacktestEngine { 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, ) } fn run_with_matching( matching_type: MatchingType, execution_price_field: PriceField, decision_lag_trading_days: usize, ) -> super::BacktestResult { engine_with_matching( matching_type, execution_price_field, decision_lag_trading_days, ) .run() .expect("backtest run") } #[test] fn minute_schedules_fire_at_each_declared_time_without_market_rows_at_those_times() { let date = d(2026, 6, 1); let observed = Rc::new(RefCell::new(Vec::new())); let strategy = MinuteScheduleProbeStrategy { rules: vec![ ScheduleRule::daily("first", ScheduleStage::Minute) .with_time_rule(ScheduleTimeRule::physical_time(10, 17)), ScheduleRule::daily("second", ScheduleStage::Minute) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ], observed: Rc::clone(&observed), }; let data = dataset_from_market_and_candidates( vec![market(date, 10.0, 10.2)], vec![candidate(date)], ); assert!( data.execution_quotes_on(date, SYMBOL).is_empty(), "the test must prove clock-driven scheduling without minute rows" ); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) .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(date), end_date: Some(date), decision_lag_trading_days: 0, execution_price_field: PriceField::Close, }; BacktestEngine::new(data, strategy, broker, config) .run() .expect("clock-driven minute schedules"); assert_eq!( observed.borrow().as_slice(), &[ date.and_time(NaiveTime::from_hms_opt(10, 17, 0).expect("first time")), date.and_time(NaiveTime::from_hms_opt(10, 18, 0).expect("second time")), ] ); } #[test] fn physical_on_day_rules_keep_each_actual_submission_time() { let date = d(2026, 7, 6); let quotes = vec![ IntradayExecutionQuote { date, symbol: SYMBOL.to_string(), timestamp: date.and_hms_opt(10, 18, 0).expect("morning timestamp"), last_price: 11.0, bid1: 10.99, ask1: 11.0, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 110_000.0, trading_phase: Some("continuous_auction".to_string()), }, IntradayExecutionQuote { date, symbol: SYMBOL.to_string(), timestamp: date.and_hms_opt(10, 19, 0).expect("future timestamp"), last_price: 99.0, bid1: 98.99, ask1: 99.0, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 990_000.0, trading_phase: Some("continuous_auction".to_string()), }, IntradayExecutionQuote { date, symbol: SYMBOL.to_string(), timestamp: date.and_hms_opt(15, 10, 0).expect("post-close timestamp"), last_price: 10.0, bid1: 10.0, ask1: 10.0, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 100_000.0, trading_phase: Some("post_close_fixed_price".to_string()), }, ]; let data = DataSet::from_components_with_actions_and_quotes( vec![default_instrument()], vec![market(date, 10.0, 10.0)], vec![factor(date)], vec![candidate(date)], vec![benchmark(date)], Vec::new(), quotes, ) .expect("timed schedule dataset"); let strategy = TimedOnDayBuyStrategy { rules: vec![ ScheduleRule::daily("morning", ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ScheduleRule::daily("post_close", ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(15, 10)), ], }; let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_matching_type(MatchingType::CurrentBarClose) .with_execution_price_field(PriceField::Last) .with_intraday_execution_start_time( NaiveTime::from_hms_opt(15, 10, 0).expect("runner fallback time"), ) .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(date), end_date: Some(date), decision_lag_trading_days: 0, execution_price_field: PriceField::Last, }; let result = BacktestEngine::new(data, strategy, broker, config) .run() .expect("timed on-day schedules"); assert_eq!(result.fills.len(), 2, "{result:?}"); assert_eq!( result.fills[0].execution_timestamp, date.and_hms_opt(10, 18, 0), "{:?}", result.fills, ); assert_eq!(result.fills[0].price, 11.0); assert_eq!( result.fills[0].execution_start_timestamp, date.and_hms_opt(10, 18, 0) ); assert_eq!( result.fills[1].execution_timestamp, date.and_hms_opt(15, 10, 0) ); assert_eq!(result.fills[1].price, 10.0); assert_eq!(result.fills[0].reason, "morning"); assert_eq!(result.fills[1].reason, "post_close"); assert_eq!( result.equity_curve[0].notes, "note:morning | note:post_close" ); assert_eq!( result.equity_curve[0].diagnostics, "diagnostic:morning | diagnostic:post_close" ); } #[test] fn minute_event_clock_uses_timestamp_order_and_coalesces_equal_heads() { let date = d(2026, 6, 1); let quote = date.and_hms_opt(10, 18, 0).expect("quote time"); let earlier_schedule = date.and_hms_opt(10, 17, 0).expect("schedule time"); assert_eq!( super::next_minute_event_timestamp(Some(quote), Some(earlier_schedule)), Some(earlier_schedule) ); assert_eq!( super::next_minute_event_timestamp(Some(quote), Some(quote)), Some(quote) ); assert_eq!( super::next_minute_event_timestamp(None, Some(earlier_schedule)), Some(earlier_schedule) ); } #[test] fn scheduled_event_detail_records_actual_time_only_for_timed_rules() { let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)); let untimed = ScheduleRule::daily("untimed", ScheduleStage::OnDay); assert_eq!( super::scheduled_event_detail( &timed, ScheduleStage::OnDay, NaiveTime::from_hms_opt(10, 18, 0), 2, "pre", ), "scheduled:timed:on_day:10:18:pre" ); assert_eq!( super::scheduled_event_detail( &untimed, ScheduleStage::OnDay, NaiveTime::from_hms_opt(15, 0, 0), 0, "post", ), "scheduled:untimed:on_day:post" ); } #[test] fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() { let date = d(2026, 7, 6); let data = dataset_from_market_and_candidates( vec![market(date, 9.5, 10.0)], vec![candidate(date)], ); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close, ) .with_matching_type(MatchingType::CurrentBarClose) .with_intraday_execution_start_time( NaiveTime::from_hms_opt(15, 0, 0).expect("valid submission time"), ) .with_slippage_model(SlippageModel::PriceRatio(0.25)) .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(date), end_date: Some(date), decision_lag_trading_days: 0, execution_price_field: PriceField::Close, }; let requests = Arc::new(Mutex::new(Vec::new())); let captured = Arc::clone(&requests); let mut engine = BacktestEngine::new( data, BuyWhenDecisionDateStrategy { decision_date: date, }, broker, config, ) .with_execution_quote_loader(move |request| { captured .lock() .expect("request capture lock") .push((request.start_time, request.end_time)); Ok(request .symbols .into_iter() .map(|symbol| IntradayExecutionQuote { date: request.date, symbol, timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"), last_price: 12.0, bid1: 11.99, ask1: 12.01, bid1_volume: 10_000, ask1_volume: 10_000, volume_delta: 10_000, amount_delta: 120_000.0, trading_phase: Some("post_close_fixed_price".to_string()), }) .collect()) }); let result = engine.run().expect("post-close backtest run"); assert_eq!( requests.lock().expect("request capture lock").as_slice(), &[( NaiveTime::from_hms_opt(15, 5, 0), NaiveTime::from_hms_opt(15, 30, 0), )] ); assert_eq!(result.fills.len(), 1, "{result:?}"); assert_eq!(result.fills[0].price, 10.0); assert_eq!( result.fills[0].execution_timestamp, date.and_hms_opt(15, 5, 0) ); } #[test] fn compact_progress_keeps_counts_without_event_payload_clones() { let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0); let mut progress = Vec::new(); let result = engine .run_with_progress_options(false, false, |event| { progress.push(( event.daily_order_count, event.daily_fill_count, event.orders.len(), event.fills.len(), event.holdings.len(), event.process_events.len(), event.notes.len(), event.diagnostics.len(), )); }) .expect("compact progress run"); assert!(!progress.is_empty()); assert!( progress .iter() .any(|(orders, fills, ..)| *orders > 0 && *fills > 0) ); assert!(progress.iter().all( |(_, _, orders, fills, holdings, process_events, notes, diagnostics)| { *orders == 0 && *fills == 0 && *holdings == 0 && *process_events == 0 && *notes == 0 && *diagnostics == 0 } )); assert_eq!( progress.iter().map(|value| value.0).sum::(), result.order_events.len() ); assert_eq!( progress.iter().map(|value| value.1).sum::(), result.fills.len() ); } fn full_day_coverage_engine( data: DataSet, date: NaiveDate, ) -> BacktestEngine { let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Last, ) .with_matching_type(MatchingType::MinuteLast) .with_volume_limit(false) .with_liquidity_limit(false); BacktestEngine::new( data, BuyWhenDecisionDateStrategy { decision_date: date, }, broker, BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(date), end_date: Some(date), decision_lag_trading_days: 0, execution_price_field: PriceField::Last, }, ) } #[test] fn full_minute_coverage_rejects_missing_active_bars_but_allows_paused_or_zero_volume() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let active = full_day_coverage_engine(dataset(), first); let error = active .validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()]) .expect_err("active stock with daily volume requires minute bars"); assert!( error.to_string().contains("missing_active_minute_bars"), "{error}" ); let paused_data = dataset_with( market_with_state(first, 10.0, 10.0, true, 11.0, 9.0), market(second, 10.0, 10.0), candidate_with_state(first, true, false), candidate(second), ); full_day_coverage_engine(paused_data, first) .validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()]) .expect("paused stock may have no minute bars"); let zero_volume_data = dataset_with( market_with_volume(first, 10.0, 10.0, 0), market(second, 10.0, 10.0), candidate(first), candidate(second), ); full_day_coverage_engine(zero_volume_data, first) .validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()]) .expect("zero-volume stock may have no minute bars"); } 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].decision_date, Some(d(2025, 1, 2))); assert_eq!(result.fills[0].order_created_date, Some(d(2025, 1, 2))); assert_eq!(result.fills[0].execution_date, Some(d(2025, 1, 3))); assert_eq!(result.fills[0].price, 12.0); } #[test] fn next_bar_open_target_portfolio_smart_sizes_with_execution_day_open() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let dataset = DataSet::from_components( vec![default_instrument()], vec![market(first, 10.0, 10.0), market(second, 12.0, 12.0)], vec![factor(first), factor(second)], vec![candidate(first), candidate(second)], vec![benchmark(first), benchmark(second)], ) .expect("dataset"); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(second), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; let mut target_weights = BTreeMap::new(); target_weights.insert(SYMBOL.to_string(), 1.0); let result = BacktestEngine::new( dataset, ScheduledTargetPortfolioSmartStrategy { rule: ScheduleRule::daily("daily_target_portfolio", ScheduleStage::OnDay), decision_date: first, target_weights, }, broker, config, ) .run() .expect("backtest run"); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, second); assert_eq!(result.fills[0].decision_date, Some(first)); assert_eq!(result.fills[0].execution_date, Some(second)); assert_eq!(result.fills[0].price, 12.0); assert_eq!(result.fills[0].quantity, 8_300); } #[test] fn next_bar_open_target_percent_freezes_decision_day_equity() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let dataset = dataset_from_market_and_candidates( vec![ market(first, 10.0, 10.0), market(second, 10.0, 10.0), market(third, 20.0, 20.0), ], vec![candidate(first), candidate(second), candidate(third)], ); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(third), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; let result = BacktestEngine::new( dataset, ScheduledTargetPercentStrategy { first_decision_date: first, second_decision_date: second, }, scheduled_next_open_broker(FidcRiskControlConfig::default()), config, ) .run() .expect("backtest run"); assert_eq!(result.fills.len(), 2, "fills={:?}", result.fills); assert_eq!(result.fills[0].date, second); assert_eq!(result.fills[0].quantity, 1_000); assert_eq!(result.fills[1].date, third); assert_eq!(result.fills[1].price, 20.0); assert_eq!(result.fills[1].quantity, 1_400); assert_eq!(result.fills[1].decision_date, Some(second)); } #[test] fn next_bar_open_executes_last_decision_without_execution_day_factor_snapshot() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let third = d(2025, 1, 6); let dataset = DataSet::from_components( vec![default_instrument()], vec![ market(first, 10.0, 11.5), market(second, 12.0, 13.0), market(third, 14.0, 15.0), ], vec![factor(first), factor(second)], vec![candidate(first), candidate(second), candidate(third)], vec![benchmark(first), benchmark(second), benchmark(third)], ) .expect("dataset"); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(third), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; let result = BacktestEngine::new( dataset, ScheduledBuyOnDecisionDateStrategy { rule: ScheduleRule::daily("daily_signal", ScheduleStage::OnDay), decision_date: second, }, broker, config, ) .run() .expect("backtest run"); assert_eq!(result.fills.len(), 1); assert_eq!(result.fills[0].date, third); assert_eq!(result.fills[0].decision_date, Some(second)); assert_eq!(result.fills[0].execution_date, Some(third)); assert_eq!(result.fills[0].price, 14.0); } #[test] fn next_bar_open_strategy_context_data_helpers_use_decision_date() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let observed = Rc::new(RefCell::new(Vec::new())); let dataset = dataset_with( market_with_state(first, 10.0, 11.5, true, 11.5, 9.0), market_with_state(second, 12.0, 99.0, false, 200.0, 1.0), candidate_with_state(first, true, false), candidate(second), ); let portfolio = PortfolioState::new(100_000.0); let subscriptions = BTreeSet::new(); let manual_ctx = StrategyContext { execution_date: second, decision_date: first, decision_index: 0, data: &dataset, portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions, process_events: &[], active_process_event: None, active_datetime: Some(second.and_hms_opt(9, 31, 0).unwrap()), order_events: &[], fills: &[], }; assert_eq!( manual_ctx .current_snapshot(SYMBOL) .map(|snapshot| snapshot.close), Some(11.5) ); assert_eq!( dataset .eligible_universe_on(first) .iter() .map(|row| row.symbol.clone()) .collect::>(), vec![SYMBOL.to_string()], "raw DataSet helper should not apply default selection risk" ); let mut selection_risk_config = FidcRiskControlConfig::default(); selection_risk_config.static_rules.reject_paused_selection = true; assert!( dataset .eligible_universe_on_with_risk_config(first, &selection_risk_config) .is_empty(), "explicit raw DataSet selection risk can still filter the universe" ); assert_eq!( manual_ctx .eligible_universe_on(first) .into_iter() .map(|row| row.symbol) .collect::>(), vec![SYMBOL.to_string()], "lagged StrategyContext should not apply signal-day execution risk to universe helpers" ); assert_eq!( manual_ctx .eligible_universe_on_with_risk_config(first, &selection_risk_config) .into_iter() .map(|row| row.symbol) .collect::>(), vec![SYMBOL.to_string()], "lagged StrategyContext should defer configured selection risk on the signal day" ); assert_eq!( manual_ctx.history_bars(SYMBOL, 2, "1d", "close", true), vec![11.5] ); assert_eq!(manual_ctx.is_suspended(SYMBOL, 1), vec![true]); let broker = scheduled_next_open_broker(FidcRiskControlConfig::default()); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(second), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; let result = BacktestEngine::new( dataset.clone(), ScheduledDataDateProbeStrategy { rule: ScheduleRule::daily("daily_data_date_probe", ScheduleStage::OnDay), expected_decision_date: first, observed: observed.clone(), }, broker, config, ) .run() .expect("backtest run"); assert_eq!( observed.borrow().as_slice(), [ "signal=2025-01-02;execution=2025-01-03;active=2025-01-02;current=11.50;history=11.50;suspended=1" ] ); 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_eligible_universe_helper_does_not_block_on_decision_day_risk() { let first = d(2025, 1, 2); let second = d(2025, 1, 3); let dataset = dataset_with( market_with_state(first, 10.0, 11.5, true, 11.5, 9.0), market_with_state(second, 12.0, 99.0, false, 200.0, 1.0), candidate_with_state(first, true, false), candidate(second), ); let config = BacktestConfig { initial_cash: 100_000.0, benchmark_code: "000852.SH".to_string(), start_date: Some(first), end_date: Some(second), decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; let result = BacktestEngine::new( dataset, ScheduledEligibleUniverseBuyStrategy { rule: ScheduleRule::daily("daily_eligible_universe_buy", ScheduleStage::OnDay), expected_decision_date: first, }, scheduled_next_open_broker(FidcRiskControlConfig::default()), config, ) .run() .expect("backtest run"); 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_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"); let execution_decision = result .risk_decisions .iter() .find(|decision| { decision.date == second && decision.symbol == SYMBOL && decision.scope == RiskCheckScope::Buy && decision.stage == "execution" && decision.rule_code == "upper_limit" && !decision.accepted }) .expect("execution-day upper limit buy rejection should be audited"); assert!( execution_decision .reason .contains("open at or above upper limit"), "{}", execution_decision.reason ); } #[test] fn next_bar_open_execution_risk_uses_open_not_close_for_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, 11.8, 12.0, false, 12.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, 11.8); assert!( result .order_events .iter() .all(|event| !event.reason.contains("upper limit")), "{:?}", result.order_events ); } #[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_risk_uses_open_not_close_for_lower_limit_sell() { 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.2, 9.0, false, 20.0, 9.0), ], vec![ candidate(first), candidate(second), candidate(third), candidate(fourth), ], ), scheduled_next_open_broker(FidcRiskControlConfig::default()), ); let sell_fill = result .fills .iter() .find(|fill| fill.side == OrderSide::Sell) .expect("sell should execute when next-open is above lower limit"); assert_eq!(sell_fill.date, fourth); assert_eq!(sell_fill.price, 9.2); assert!( result .order_events .iter() .all(|event| !event.reason.contains("lower limit")), "{:?}", result.order_events ); } #[test] fn next_bar_open_sell_respects_allow_sell_policy_on_execution_day() { 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 dataset = 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, 12.0, 12.2), ], vec![ candidate(first), candidate(second), candidate(third), candidate_with_sell_state(fourth, false, false), ], ); let rejected = run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset.clone(), scheduled_next_open_broker(FidcRiskControlConfig::default()), ); assert_round_trip_sell_canceled_with_reason(&rejected, "sell_disabled"); let execution_decision = rejected .risk_decisions .iter() .find(|decision| { decision.date == fourth && decision.symbol == SYMBOL && decision.scope == RiskCheckScope::Sell && decision.stage == "execution" && decision.rule_code == "sell_disabled" && !decision.accepted }) .expect("execution-day sell_disabled rejection should be audited"); assert_eq!(execution_decision.order_id.as_deref(), Some("2")); let mut relaxed_config = FidcRiskControlConfig::default(); relaxed_config.static_rules.respect_allow_buy_sell = false; let relaxed = run_scheduled_round_trip_next_open_with_dataset_and_broker( dataset, scheduled_next_open_broker(relaxed_config), ); let sell_fill = relaxed .fills .iter() .find(|fill| fill.side == OrderSide::Sell) .expect("sell should execute when allow_sell policy is disabled"); assert_eq!(sell_fill.date, fourth); assert_eq!(sell_fill.price, 12.0); } #[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") })); } }