diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9ec0e9b..fca7803 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -384,6 +384,10 @@ impl BrokerSimulator { }) .collect() } + + pub fn has_open_orders(&self) -> bool { + !self.open_orders.borrow().is_empty() + } } impl BrokerSimulator diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index c53c8eb..c821809 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; +use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Timelike}; use serde::Serialize; use thiserror::Error; @@ -1086,6 +1086,10 @@ where views } + fn has_open_orders(&self) -> bool { + self.broker.has_open_orders() || !self.futures_open_orders.is_empty() + } + fn aggregate_initial_cash(&self) -> f64 { self.config.initial_cash + self @@ -2486,6 +2490,20 @@ where !filter_by_subscription || self.subscriptions.contains("e.symbol) }) .collect::>(); + let requires_minute_callbacks = self.strategy.requires_minute_callbacks(); + let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[ + ProcessEventKind::PreMinute, + ProcessEventKind::Minute, + ProcessEventKind::PostMinute, + ]); + let minute_schedule_all_times = schedule_rules + .iter() + .any(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()); + let minute_schedule_minutes = schedule_rules + .iter() + .filter(|rule| rule.stage == ScheduleStage::Minute) + .filter_map(|rule| rule.time_rule.as_ref()?.minute_of_day()) + .collect::>(); let mut minute_cursor = 0usize; while minute_cursor < minute_quotes.len() { let minute_timestamp = minute_quotes[minute_cursor].timestamp; @@ -2497,6 +2515,17 @@ where minute_end += 1; } let minute_group = &minute_quotes[minute_cursor..minute_end]; + let schedule_candidate = minute_schedule_all_times + || minute_schedule_minutes + .contains(&(minute_time.hour() * 60 + minute_time.minute())); + if !requires_minute_callbacks + && !has_minute_process_listeners + && !schedule_candidate + && !self.has_open_orders() + { + minute_cursor = minute_end; + continue; + } let minute_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, @@ -2535,26 +2564,28 @@ where result.order_events.as_slice(), result.fills.as_slice(), )?; - for quote in minute_group { - minute_decision.merge_from(self.strategy.on_minute( - &StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &minute_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: Some(minute_timestamp), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - }, - quote, - )?); + if requires_minute_callbacks { + for quote in minute_group { + minute_decision.merge_from(self.strategy.on_minute( + &StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &minute_open_orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events: &process_events, + active_process_event: None, + active_datetime: Some(minute_timestamp), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), + }, + quote, + )?); + } } publish_phase_event( &mut self.strategy, diff --git a/crates/fidc-core/src/event_bus.rs b/crates/fidc-core/src/event_bus.rs index 3a880fa..f94261e 100644 --- a/crates/fidc-core/src/event_bus.rs +++ b/crates/fidc-core/src/event_bus.rs @@ -125,6 +125,15 @@ impl ProcessEventBus { loader.install_enabled(self, enabled_names) } + pub fn has_listeners_for(&self, kinds: &[ProcessEventKind]) -> bool { + !self.any_listeners.is_empty() + || kinds.iter().any(|kind| { + self.listeners + .get(kind) + .is_some_and(|listeners| !listeners.is_empty()) + }) + } + pub fn publish(&mut self, event: &ProcessEvent) { if let Some(listeners) = self.listeners.get_mut(&event.kind) { for listener in listeners { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 315e19b..b5ab1e7 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -10057,6 +10057,10 @@ impl Strategy for PlatformExprStrategy { self.config.initial_subscriptions.clone() } + fn requires_minute_callbacks(&self) -> bool { + false + } + fn schedule_rules(&self) -> Vec { if self.config.explicit_action_stage != PlatformExplicitActionStage::Minute { return Vec::new(); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index c8d0021..ae198b2 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -22,6 +22,9 @@ pub trait Strategy { fn initial_subscriptions(&self) -> BTreeSet { BTreeSet::new() } + fn requires_minute_callbacks(&self) -> bool { + true + } fn management_fee( &mut self, _ctx: &StrategyContext<'_>, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 6161346..43a441a 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -637,6 +637,7 @@ struct MinuteProbeStrategy { seen_ticks: Rc>>, scheduled_count: Rc>, subscribe_symbols: BTreeSet, + minute_callbacks: bool, ordered: bool, } @@ -812,6 +813,10 @@ impl Strategy for MinuteProbeStrategy { "minute-probe" } + fn requires_minute_callbacks(&self) -> bool { + self.minute_callbacks + } + fn schedule_rules(&self) -> Vec { vec![ ScheduleRule::daily("minute_barrier", ScheduleStage::Minute) @@ -2066,6 +2071,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() { seen_ticks: seen_ticks.clone(), scheduled_count: scheduled_count.clone(), subscribe_symbols: BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()]), + minute_callbacks: true, ordered: false, }; let broker = BrokerSimulator::new_with_execution_price( @@ -2143,6 +2149,82 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() { ); } +#[test] +fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() { + let date = d(2025, 1, 2); + let mut data = single_day_anchor_data(date); + data.add_execution_quotes(vec![ + IntradayExecutionQuote { + date, + symbol: "000001.SZ".to_string(), + timestamp: dt(2025, 1, 2, 10, 18, 0), + last_price: 10.2, + bid1: 10.1, + ask1: 10.2, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 1_000, + amount_delta: 10_200.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000001.SZ".to_string(), + timestamp: dt(2025, 1, 2, 10, 19, 0), + last_price: 10.3, + bid1: 10.2, + ask1: 10.3, + bid1_volume: 1_000, + ask1_volume: 1_000, + volume_delta: 1_000, + amount_delta: 10_300.0, + trading_phase: Some("continuous".to_string()), + }, + ]); + let seen_ticks = Rc::new(RefCell::new(Vec::new())); + let scheduled_count = Rc::new(RefCell::new(0usize)); + let strategy = MinuteProbeStrategy { + seen_ticks: seen_ticks.clone(), + scheduled_count: scheduled_count.clone(), + subscribe_symbols: BTreeSet::from(["000001.SZ".to_string()]), + minute_callbacks: false, + ordered: false, + }; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ); + let mut engine = BacktestEngine::new( + data, + strategy, + broker, + BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .with_execution_quote_loader(|_| Ok(Vec::new())); + + let result = engine.run().expect("scheduled-only minute run"); + + assert!(seen_ticks.borrow().is_empty()); + assert_eq!(*scheduled_count.borrow(), 1); + assert!(result.fills.is_empty()); + assert_eq!( + result + .process_events + .iter() + .filter(|event| event.kind == ProcessEventKind::PreMinute) + .count(), + 1 + ); +} + #[test] fn strategy_context_exposes_engine_native_data_helpers() { let date1 = d(2025, 1, 2);