diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index d485ec9..a304e08 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1850,7 +1850,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::BeforeTrading), ), order_events: result.order_events.as_slice(), @@ -1970,7 +1970,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::OpenAuction), ), order_events: result.order_events.as_slice(), @@ -2086,7 +2086,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::OnDay), ), order_events: result.order_events.as_slice(), @@ -2118,7 +2118,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::OnDay), ), order_events: result.order_events.as_slice(), @@ -2215,7 +2215,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::Bar), ), order_events: result.order_events.as_slice(), @@ -2519,7 +2519,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::AfterTrading), ), order_events: visible_order_events.as_slice(), @@ -2653,7 +2653,7 @@ where process_events: &process_events, active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::Settlement), ), order_events: visible_order_events_after_close.as_slice(), @@ -3243,7 +3243,7 @@ where process_events: process_events.as_slice(), active_process_event: None, active_datetime: stage_datetime( - execution_date, + decision_date, default_stage_time(ScheduleStage::Settlement), ), order_events, @@ -3594,7 +3594,11 @@ fn collect_scheduled_decisions( fills: &[FillEvent], ) -> Result { let mut combined = crate::strategy::StrategyDecision::default(); - for rule in scheduler.triggered_rules_at(execution_date, stage, current_time, rules) { + // 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) { publish_phase_event( strategy, process_event_bus, @@ -3625,7 +3629,7 @@ fn collect_scheduled_decisions( subscriptions, process_events: process_events.as_slice(), active_process_event: None, - active_datetime: stage_datetime(execution_date, current_time), + active_datetime: stage_datetime(decision_date, current_time), order_events, fills, }, @@ -3942,8 +3946,10 @@ mod tests { BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField, }; + use crate::events::OrderStatus; use crate::instrument::Instrument; use crate::rules::ChinaEquityRuleHooks; + use crate::scheduler::{ScheduleRule, ScheduleStage}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; const SYMBOL: &str = "000001.SZ"; @@ -3976,6 +3982,46 @@ mod tests { } } + #[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()) + } + } + fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } @@ -4006,6 +4052,22 @@ mod tests { } } + 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 factor(date: NaiveDate) -> DailyFactorSnapshot { DailyFactorSnapshot { date, @@ -4035,6 +4097,18 @@ mod tests { } } + fn candidate_with_state( + date: NaiveDate, + is_paused: bool, + allow_buy: bool, + ) -> CandidateEligibility { + CandidateEligibility { + is_paused, + allow_buy, + ..candidate(date) + } + } + fn benchmark(date: NaiveDate) -> BenchmarkSnapshot { BenchmarkSnapshot { date, @@ -4049,6 +4123,22 @@ mod tests { 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 dataset_with( + 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 { symbol: SYMBOL.to_string(), @@ -4059,9 +4149,9 @@ mod tests { delisted_at: None, status: "active".to_string(), }], - vec![market(first, 10.0, 11.5), market(second, 12.0, 99.0)], + vec![first_market, second_market], vec![factor(first), factor(second)], - vec![candidate(first), candidate(second)], + vec![first_candidate, second_candidate], vec![benchmark(first), benchmark(second)], ) .expect("dataset") @@ -4103,6 +4193,39 @@ mod tests { .expect("backtest run") } + fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult { + let first = d(2025, 1, 2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .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: 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") + } + #[test] fn current_bar_close_uses_decision_day_close_for_fill() { let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0); @@ -4127,4 +4250,67 @@ mod tests { 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].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_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!(result.fills.is_empty()); + assert!(result.order_events.iter().any(|event| { + event.date == second + && event.status == OrderStatus::Canceled + && event.reason.contains("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!(result.fills.is_empty()); + assert!(result.order_events.iter().any(|event| { + event.date == second + && event.status == OrderStatus::Canceled + && event.reason.contains("open at or above upper limit") + })); + } }