diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 8bb707d..9087b90 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -458,6 +458,60 @@ pub struct BacktestEngine { 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, @@ -1977,52 +2031,16 @@ where self.subscriptions = self.strategy.initial_subscriptions(); let scheduler_calendar = self.data.calendar().clone(); let scheduler = Scheduler::new(&scheduler_calendar); - let calendar_dates = self - .data - .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() - .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)) + .map(|(execution_date, _)| *execution_date) .collect::>(); - let has_decision_inputs = |date: NaiveDate| { - !self.data.factor_snapshot_rows_on(date).is_empty() - && !self.data.candidate_snapshot_rows_on(date).is_empty() - }; - let has_execution_market = - |date: NaiveDate| !self.data.market_snapshot_rows_on(date).is_empty(); - let mut execution_dates = Vec::new(); - let mut decision_slots = Vec::new(); - for (calendar_idx, execution_date) in calendar_dates.iter().copied().enumerate() { - if self.config.decision_lag_trading_days == 0 { - if has_decision_inputs(execution_date) { - execution_dates.push(execution_date); - decision_slots.push(Some((calendar_idx, execution_date))); - } - continue; - } - if !has_execution_market(execution_date) { - continue; - } - let decision_slot = calendar_idx - .checked_sub(self.config.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) => { - execution_dates.push(execution_date); - decision_slots.push(decision_slot); - } - None => { - execution_dates.push(execution_date); - decision_slots.push(None); - } - _ => {} - } - } let mut result = BacktestResult { strategy_name: self.strategy.name().to_string(), benchmark_series: self @@ -2117,7 +2135,9 @@ where let day_order_start = result.order_events.len(); let day_fill_start = result.fills.len(); - let decision_slot = decision_slots.get(execution_idx).copied().flatten(); + 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(); @@ -5452,6 +5472,28 @@ mod tests { .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, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index f77456b..e6cbf1c 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -39,7 +39,7 @@ pub use engine::{ AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, - ProcessEventRetention, + ProcessEventRetention, backtest_execution_dates, }; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{