diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 56750f4..a318cf1 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2043,7 +2043,7 @@ where ProcessEventKind::BeforeTrading, "before_trading", )?; - let mut before_trading_decision = collect_scheduled_decisions( + let mut before_trading_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, &scheduler, execution_date, @@ -2059,7 +2059,6 @@ where &self.subscriptions, &mut process_events, &mut self.process_event_bus, - default_stage_time(ScheduleStage::BeforeTrading), result.order_events.as_slice(), result.fills.as_slice(), )?; @@ -2107,7 +2106,7 @@ where ProcessEventKind::PreOpenAuction, "open_auction:pre", )?; - let mut auction_decision = collect_scheduled_decisions( + let mut auction_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, &scheduler, execution_date, @@ -2123,7 +2122,6 @@ where &self.subscriptions, &mut process_events, &mut self.process_event_bus, - default_stage_time(ScheduleStage::OpenAuction), result.order_events.as_slice(), result.fills.as_slice(), )?; @@ -2300,7 +2298,7 @@ where }) .transpose()? .unwrap_or_default(); - decision.merge_from(collect_scheduled_decisions( + decision.merge_from(collect_scheduled_decisions_for_stage( &mut self.strategy, &scheduler, execution_date, @@ -2316,7 +2314,6 @@ where &self.subscriptions, &mut process_events, &mut self.process_event_bus, - default_stage_time(ScheduleStage::OnDay), result.order_events.as_slice(), result.fills.as_slice(), )?); @@ -2355,7 +2352,7 @@ where ProcessEventKind::PreBar, "bar:pre", )?; - decision.merge_from(collect_scheduled_decisions( + decision.merge_from(collect_scheduled_decisions_for_stage( &mut self.strategy, &scheduler, execution_date, @@ -2371,7 +2368,6 @@ where &self.subscriptions, &mut process_events, &mut self.process_event_bus, - default_stage_time(ScheduleStage::Bar), result.order_events.as_slice(), result.fills.as_slice(), )?); @@ -2770,7 +2766,7 @@ where ProcessEventKind::AfterTrading, "after_trading", )?; - let mut after_trading_decision = collect_scheduled_decisions( + let mut after_trading_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, &scheduler, execution_date, @@ -2786,7 +2782,6 @@ where &self.subscriptions, &mut process_events, &mut self.process_event_bus, - default_stage_time(ScheduleStage::AfterTrading), result.order_events.as_slice(), result.fills.as_slice(), )?; @@ -2899,7 +2894,7 @@ where ProcessEventKind::Settlement, "settlement", )?; - let mut settlement_decision = collect_scheduled_decisions( + let mut settlement_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, &scheduler, execution_date, @@ -2915,7 +2910,6 @@ where &self.subscriptions, &mut process_events, &mut self.process_event_bus, - default_stage_time(ScheduleStage::Settlement), result.order_events.as_slice(), result.fills.as_slice(), )?; @@ -3845,6 +3839,70 @@ fn collect_scheduled_decisions( Ok(combined) } +fn collect_scheduled_decisions_for_stage( + strategy: &mut S, + scheduler: &Scheduler<'_>, + execution_date: NaiveDate, + stage: ScheduleStage, + rules: &[ScheduleRule], + decision_date: NaiveDate, + decision_index: usize, + data: &crate::data::DataSet, + portfolio: &PortfolioState, + futures_account: Option<&FuturesAccountState>, + open_orders: &[crate::strategy::OpenOrderView], + dynamic_universe: Option<&BTreeSet>, + subscriptions: &BTreeSet, + process_events: &mut Vec, + process_event_bus: &mut ProcessEventBus, + order_events: &[OrderEvent], + fills: &[FillEvent], +) -> Result { + let mut times = BTreeSet::new(); + for rule in rules.iter().filter(|rule| rule.stage == stage) { + let time = match rule.time_rule.as_ref() { + Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(value)) => { + let hour = value / 60; + let minute = value % 60; + Some(NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| { + BacktestError::Execution(format!( + "invalid schedule minute-of-day {} for rule {}", + value, rule.name + )) + })?) + } + Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) | None => { + default_stage_time(stage) + } + }; + times.insert(time); + } + let mut combined = crate::strategy::StrategyDecision::default(); + for time in times { + combined.merge_from(collect_scheduled_decisions( + strategy, + scheduler, + execution_date, + stage, + rules, + decision_date, + decision_index, + data, + portfolio, + futures_account, + open_orders, + dynamic_universe, + subscriptions, + process_events, + process_event_bus, + time, + order_events, + fills, + )?); + } + Ok(combined) +} + fn publish_phase_event( strategy: &mut S, process_event_bus: &mut ProcessEventBus, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 07ad337..66e6019 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -8532,24 +8532,6 @@ impl PlatformExprStrategy { }) } - fn explicit_actions_active( - &self, - calendar: &crate::calendar::TradingCalendar, - date: NaiveDate, - ) -> bool { - let stage = match self.config.explicit_action_stage { - PlatformExplicitActionStage::OpenAuction => ScheduleStage::OpenAuction, - PlatformExplicitActionStage::OnDay => ScheduleStage::OnDay, - PlatformExplicitActionStage::Minute => ScheduleStage::Minute, - }; - self.config - .explicit_action_schedule - .as_ref() - .is_none_or(|schedule| { - schedule.matches(calendar, date, stage, default_stage_time(stage)) - }) - } - fn stock_passes_expr( &self, ctx: &StrategyContext<'_>, @@ -10148,13 +10130,18 @@ impl Strategy for PlatformExprStrategy { } fn schedule_rules(&self) -> Vec { - if self.config.explicit_action_stage != PlatformExplicitActionStage::Minute { + if self.config.explicit_actions.is_empty() { return Vec::new(); } + let stage = match self.config.explicit_action_stage { + PlatformExplicitActionStage::OpenAuction => ScheduleStage::OpenAuction, + PlatformExplicitActionStage::OnDay => ScheduleStage::OnDay, + PlatformExplicitActionStage::Minute => ScheduleStage::Minute, + }; self.config .explicit_action_schedule .as_ref() - .map(|schedule| schedule.as_schedule_rule(ScheduleStage::Minute)) + .map(|schedule| schedule.as_schedule_rule(stage)) .into_iter() .collect() } @@ -10164,9 +10151,7 @@ impl Strategy for PlatformExprStrategy { ctx: &StrategyContext<'_>, _rule: &ScheduleRule, ) -> Result { - if self.config.explicit_action_stage == PlatformExplicitActionStage::Minute - && !self.config.explicit_actions.is_empty() - { + if !self.config.explicit_actions.is_empty() { return self.explicit_action_decision(ctx); } Ok(StrategyDecision::default()) @@ -10210,7 +10195,7 @@ impl Strategy for PlatformExprStrategy { ) -> Result { if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction && !self.config.explicit_actions.is_empty() - && self.explicit_actions_active(ctx.data.calendar(), ctx.decision_date) + && self.config.explicit_action_schedule.is_none() { return self.explicit_action_decision(ctx); } @@ -10264,7 +10249,7 @@ impl Strategy for PlatformExprStrategy { self.selection_dates(ctx); let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window && self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay - && self.explicit_actions_active(ctx.data.calendar(), signal_date) + && self.config.explicit_action_schedule.is_none() { self.explicit_action_intents(ctx, decision_date, &day)? } else { @@ -11841,8 +11826,8 @@ mod tests { FactorTextValue, FuturesCommissionType, FuturesTradingParameter, Instrument, IntradayExecutionQuote, MatchingType, OpenOrderView, OrderIntent, OrderSide, PortfolioState, ProcessEvent, ProcessEventKind, RebalanceCashMode, ScheduleStage, - ScheduleTimeRule, SlippageModel, Strategy, StrategyContext, TargetPortfolioOrderPricing, - TradingCalendar, default_stage_time, + ScheduleTimeRule, Scheduler, SlippageModel, Strategy, StrategyContext, + TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -31286,7 +31271,25 @@ mod tests { }]; let mut strategy = PlatformExprStrategy::new(cfg); - let auction_decision = strategy.open_auction(&ctx).expect("auction decision"); + assert!( + strategy + .open_auction(&ctx) + .expect("direct auction decision") + .order_intents + .is_empty() + ); + let rules = strategy.schedule_rules(); + let scheduler = Scheduler::new(ctx.data.calendar()); + let triggered = scheduler.triggered_rules_at( + date, + ScheduleStage::OpenAuction, + default_stage_time(ScheduleStage::OpenAuction), + &rules, + ); + assert_eq!(triggered.len(), 1); + let auction_decision = strategy + .on_scheduled(&ctx, triggered[0]) + .expect("scheduled auction decision"); assert_eq!(auction_decision.order_intents.len(), 1); assert!( auction_decision @@ -31411,8 +31414,25 @@ mod tests { }]; let mut strategy = PlatformExprStrategy::new(cfg); - let auction_decision = strategy.open_auction(&ctx).expect("auction decision"); - assert!(auction_decision.order_intents.is_empty()); + assert!( + strategy + .open_auction(&ctx) + .expect("direct auction decision") + .order_intents + .is_empty() + ); + let rules = strategy.schedule_rules(); + let scheduler = Scheduler::new(ctx.data.calendar()); + assert!( + scheduler + .triggered_rules_at( + date, + ScheduleStage::OpenAuction, + default_stage_time(ScheduleStage::OpenAuction), + &rules, + ) + .is_empty() + ); } #[test] diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 43a441a..7a9db43 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -678,6 +678,8 @@ impl Strategy for ScheduledProbeStrategy { .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay) .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), + ScheduleRule::daily("daily_on_day_close", ScheduleStage::OnDay) + .with_time_rule(ScheduleTimeRule::physical_time(15, 0)), ] } @@ -3217,12 +3219,15 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() { "scheduled:daily_before_trading:2025-01-30", "scheduled:daily_market_open:2025-01-30", "scheduled:first_trading_day_on_day:2025-01-30", + "scheduled:daily_on_day_close:2025-01-30", "scheduled:daily_before_trading:2025-01-31", "scheduled:daily_market_open:2025-01-31", "scheduled:friday_on_day:2025-01-31", + "scheduled:daily_on_day_close:2025-01-31", "scheduled:daily_before_trading:2025-02-03", "scheduled:daily_market_open:2025-02-03", "scheduled:first_trading_day_on_day:2025-02-03", + "scheduled:daily_on_day_close:2025-02-03", ] ); let process_log = process_log.borrow();