use chrono::NaiveDate; use serde::Serialize; use thiserror::Error; use crate::broker::{BrokerExecutionReport, BrokerSimulator}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; use crate::strategy::{Strategy, StrategyContext}; #[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, Serialize)] pub struct DailyEquityPoint { #[serde(with = "date_format")] pub date: NaiveDate, pub cash: f64, pub market_value: f64, pub total_equity: f64, pub benchmark_close: f64, pub notes: String, pub diagnostics: String, } #[derive(Debug, Clone)] pub struct BacktestResult { pub strategy_name: String, pub equity_curve: Vec, pub benchmark_series: Vec, pub order_events: Vec, pub fills: Vec, pub position_events: Vec, pub account_events: Vec, pub holdings_summary: Vec, pub daily_holdings: Vec, pub metrics: BacktestMetrics, } #[derive(Debug, Clone, Serialize)] pub struct BacktestDayProgress { #[serde(with = "date_format")] pub date: NaiveDate, pub cash: f64, pub market_value: f64, pub total_equity: f64, pub unit_nav: f64, pub total_return: f64, pub benchmark_close: f64, pub daily_fill_count: usize, pub cumulative_trade_count: usize, pub holding_count: usize, pub notes: String, pub diagnostics: String, pub orders: Vec, pub fills: Vec, pub holdings: Vec, } pub struct BacktestEngine { data: DataSet, strategy: S, broker: BrokerSimulator, config: BacktestConfig, } impl BacktestEngine { pub fn new( data: DataSet, strategy: S, broker: BrokerSimulator, config: BacktestConfig, ) -> Self { Self { data, strategy, broker, config, } } } impl BacktestEngine where S: Strategy, C: CostModel, R: EquityRuleHooks, { pub fn run(&mut self) -> Result { self.run_with_progress(|_| {}) } pub fn run_with_progress( &mut self, mut on_progress: F, ) -> Result where F: FnMut(&BacktestDayProgress), { let mut portfolio = PortfolioState::new(self.config.initial_cash); let execution_dates = self .data .calendar() .iter() .filter(|date| { self.config .start_date .map(|start| *date >= start) .unwrap_or(true) }) .filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true)) .filter(|date| { !self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty() }) .collect::>(); let mut result = BacktestResult { strategy_name: self.strategy.name().to_string(), benchmark_series: self .data .benchmark_series() .into_iter() .filter(|row| { self.config .start_date .map(|start| row.date >= start) .unwrap_or(true) }) .filter(|row| { self.config .end_date .map(|end| row.date <= end) .unwrap_or(true) }) .collect(), order_events: Vec::new(), fills: Vec::new(), position_events: Vec::new(), account_events: Vec::new(), equity_curve: Vec::new(), holdings_summary: Vec::new(), daily_holdings: Vec::new(), metrics: BacktestMetrics::default(), }; for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); let receivable_report = self.settle_cash_receivables( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, receivable_report); let delisting_report = self.settle_delisted_positions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, delisting_report); let corporate_action_report = self.apply_corporate_actions( execution_date, &mut portfolio, &mut corporate_action_notes, )?; self.extend_result(&mut result, corporate_action_report); let decision = execution_idx .checked_sub(self.config.decision_lag_trading_days) .map(|decision_idx| { let decision_date = execution_dates[decision_idx]; self.strategy.on_day(&StrategyContext { execution_date, decision_date, decision_index: decision_idx, data: &self.data, portfolio: &portfolio, }) }) .transpose()? .unwrap_or_default(); let report = self.broker .execute(execution_date, &mut portfolio, &self.data, &decision)?; let daily_fill_count = report.fill_events.len(); let day_orders = report.order_events.clone(); let day_fills = report.fill_events.clone(); self.extend_result(&mut result, report); portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; let benchmark = self.data .benchmark(execution_date) .ok_or(BacktestError::MissingBenchmark { date: execution_date, })?; let notes = corporate_action_notes .into_iter() .chain(decision.notes.into_iter()) .collect::>() .join(" | "); let diagnostics = decision.diagnostics.join(" | "); let holdings_for_day = portfolio.holdings_summary(execution_date); result.equity_curve.push(DailyEquityPoint { date: execution_date, cash: portfolio.cash(), market_value: portfolio.market_value(), total_equity: portfolio.total_equity(), benchmark_close: benchmark.close, notes, diagnostics, }); result.daily_holdings.extend(holdings_for_day.clone()); let latest = result .equity_curve .last() .expect("equity point pushed for progress event"); on_progress(&BacktestDayProgress { date: execution_date, cash: latest.cash, market_value: latest.market_value, total_equity: latest.total_equity, unit_nav: if self.config.initial_cash.abs() < f64::EPSILON { 0.0 } else { latest.total_equity / self.config.initial_cash }, total_return: if self.config.initial_cash.abs() < f64::EPSILON { 0.0 } else { (latest.total_equity / self.config.initial_cash) - 1.0 }, benchmark_close: latest.benchmark_close, daily_fill_count, cumulative_trade_count: result.fills.len(), holding_count: holdings_for_day.len(), notes: latest.notes.clone(), diagnostics: latest.diagnostics.clone(), orders: day_orders, fills: day_fills, holdings: holdings_for_day, }); } if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } result.metrics = compute_backtest_metrics( &result.equity_curve, &result.fills, &result.daily_holdings, self.config.initial_cash, ); Ok(result) } fn extend_result( &self, result: &mut BacktestResult, report: BrokerExecutionReport, ) -> BrokerExecutionReport { result.order_events.extend(report.order_events.clone()); result.fills.extend(report.fill_events.clone()); result .position_events .extend(report.position_events.clone()); result.account_events.extend(report.account_events.clone()); report } fn apply_corporate_actions( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); for action in self.data.corporate_actions_on(date) { if !action.has_effect() { continue; } let Some(existing_position) = portfolio.position(&action.symbol) else { continue; }; if existing_position.quantity == 0 { continue; } if 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 = position.apply_cash_dividend(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); let immediate_cash = payable_date <= date; let note = if immediate_cash { portfolio.apply_cash_delta(cash_delta); format!( "cash_dividend {} share_cash={:.6} quantity={} cash={:.2}", action.symbol, action.share_cash, quantity_after, cash_delta ) } else { 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), }); 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), }); } } } portfolio.prune_flat_positions(); Ok(report) } fn settle_cash_receivables( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); let settled = portfolio.settle_cash_receivables(date); for receivable in settled { let note = format!( "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount ); notes.push(note.clone()); report.account_events.push(AccountEvent { date, cash_before: portfolio.cash() - receivable.amount, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note, }); } Ok(report) } fn settle_delisted_positions( &self, date: NaiveDate, portfolio: &mut PortfolioState, notes: &mut Vec, ) -> Result { let mut report = BrokerExecutionReport::default(); let symbols = portfolio.positions().keys().cloned().collect::>(); for symbol in symbols { let Some(position) = portfolio.position(&symbol) else { continue; }; if position.quantity == 0 { continue; } let Some(instrument) = self.data.instrument(&symbol) else { continue; }; let should_settle = instrument.is_delisted_before(date) || (instrument.status.eq_ignore_ascii_case("delisted") && instrument.delisted_at.is_none() && self.data.market(date, &symbol).is_none()); if !should_settle { continue; } let quantity = position.quantity; let fallback_reference_price = if position.last_price > 0.0 { position.last_price } else { position.average_cost }; let effective_delisted_at = instrument .delisted_at .or_else(|| self.data.calendar().previous_day(date)) .unwrap_or(date); let settlement_price = self .data .price_on_or_before(effective_delisted_at, &symbol, PriceField::Close) .or_else(|| { self.data .price_on_or_before(date, &symbol, PriceField::Close) }) .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(fallback_reference_price); if !settlement_price.is_finite() || settlement_price <= 0.0 { return Err(BacktestError::Execution(format!( "missing delisting settlement price for {} on {}", symbol, date ))); } let cash_before = portfolio.cash(); let gross_amount = settlement_price * quantity as f64; let realized_pnl_delta = { let position = portfolio .position_mut_if_exists(&symbol) .expect("position exists for delisting settlement"); position .sell(quantity, settlement_price) .map_err(BacktestError::Execution)? }; portfolio.apply_cash_delta(gross_amount); portfolio.prune_flat_positions(); let reason = format!( "delisted_cash_settlement effective_date={} status={}", effective_delisted_at, instrument.status ); notes.push(reason.clone()); report.order_events.push(OrderEvent { date, symbol: symbol.clone(), side: OrderSide::Sell, requested_quantity: quantity, filled_quantity: quantity, status: OrderStatus::Filled, reason: reason.clone(), }); report.fill_events.push(FillEvent { date, symbol: symbol.clone(), side: OrderSide::Sell, quantity, price: settlement_price, gross_amount, commission: 0.0, stamp_tax: 0.0, net_cash_flow: gross_amount, reason: reason.clone(), }); report.position_events.push(PositionEvent { date, symbol: symbol.clone(), delta_quantity: -(quantity as i32), quantity_after: 0, average_cost: 0.0, realized_pnl_delta, reason: reason.clone(), }); report.account_events.push(AccountEvent { date, cash_before, cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), note: reason, }); } Ok(report) } } mod date_format { use chrono::NaiveDate; use serde::Serializer; const FORMAT: &str = "%Y-%m-%d"; pub fn serialize(date: &NaiveDate, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&date.format(FORMAT).to_string()) } }