diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index b67b9fe..38296f0 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1652,7 +1652,7 @@ where let mut portfolio = PortfolioState::new(self.config.initial_cash); let scheduler_calendar = self.data.calendar().clone(); let scheduler = Scheduler::new(&scheduler_calendar); - let execution_dates = self + let calendar_dates = self .data .calendar() .iter() @@ -1663,11 +1663,41 @@ where .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 has_decision_inputs = |date: NaiveDate| { + !self.data.factor_snapshots_on(date).is_empty() + && !self.data.candidate_snapshots_on(date).is_empty() + }; + let has_execution_market = + |date: NaiveDate| !self.data.market_snapshots_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 @@ -1757,9 +1787,7 @@ where execution_date, ); - let decision_slot = execution_idx - .checked_sub(self.config.decision_lag_trading_days) - .map(|decision_idx| (decision_idx, execution_dates[decision_idx])); + let decision_slot = decision_slots.get(execution_idx).copied().flatten(); let Some((decision_index, decision_date)) = decision_slot else { let mut process_events = Vec::new(); let mut report = BrokerExecutionReport::default(); @@ -4858,6 +4886,52 @@ mod tests { assert_eq!(result.fills[0].price, 12.0); } + #[test] + fn next_bar_open_executes_last_decision_on_market_only_execution_day() { + 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)], + 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, + ScheduledBuyStrategy { + rule: ScheduleRule::weekly_by_weekday("weekly_signal", 5, ScheduleStage::OnDay), + expected_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);