diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a3ac26d..76c40a5 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4150,6 +4150,46 @@ mod tests { } } + #[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, @@ -4777,6 +4817,28 @@ mod tests { .map(|snapshot| snapshot.close), Some(11.5) ); + assert!( + dataset.eligible_universe_on(first).is_empty(), + "raw DataSet helper remains a risk-filtered 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, &FidcRiskControlConfig::default()) + .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] @@ -4816,6 +4878,42 @@ mod tests { 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); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 4b58c9b..8370b37 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -526,14 +526,17 @@ impl StrategyContext<'_> { &self, date: NaiveDate, ) -> Vec { - let eligible = self.data.eligible_universe_on(date); + let eligible = if self.is_lagged_execution() && date == self.decision_date { + self.data.fundamental_universe_on(date) + } else { + self.data.eligible_universe_on(date).to_vec() + }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible - .iter() + .into_iter() .filter(|row| symbols.contains(&row.symbol)) - .cloned() .collect(), - _ => eligible.to_vec(), + _ => eligible, } } @@ -542,9 +545,12 @@ impl StrategyContext<'_> { date: NaiveDate, risk_config: &crate::risk_control::FidcRiskControlConfig, ) -> Vec { - let eligible = self - .data - .eligible_universe_on_with_risk_config(date, risk_config); + let eligible = if self.is_lagged_execution() && date == self.decision_date { + self.data.fundamental_universe_on(date) + } else { + self.data + .eligible_universe_on_with_risk_config(date, risk_config) + }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible .into_iter()