From 4edc70c4c633cf5d6d8d9e23340d808bce13ce9d Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 05:05:39 +0800 Subject: [PATCH] fix(backtest): advance resting limit orders on subsequent quote events --- crates/fidc-core/src/broker.rs | 2 +- crates/fidc-core/src/engine.rs | 101 ++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9cfa338..82ff94b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -7961,7 +7961,7 @@ where quote.volume_delta > 0 && quote.bid1_volume == 0 && quote.ask1_volume == 0 } - fn matching_type_uses_intraday_quotes(&self) -> bool { + pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool { matches!( self.matching_type, MatchingType::MinuteLast diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 01574bf..843d0a7 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2849,9 +2849,16 @@ where "bar:post", )?; - if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) { - if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() { - let mut minute_symbols = self.subscriptions.clone(); + if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) + || (self.broker.has_open_orders() && self.broker.matching_type_uses_intraday_quotes()) + { + let unfiltered_minute_stream = self.subscriptions.is_empty(); + let mut full_minute_symbols = self.subscriptions.clone(); + if self.broker.matching_type_uses_intraday_quotes() { + full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol)); + } + if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() { + let mut minute_symbols = full_minute_symbols.clone(); self.load_missing_execution_quotes( execution_date, None, @@ -2862,11 +2869,11 @@ where // Keep the iterator attached to an O(1) DataSet clone. This // preserves the immutable quote snapshot for the day while // allowing lazy quote loads and broker state updates on self. - let quote_data = self.data.clone(); + let mut quote_data = self.data.clone(); let mut minute_quotes = quote_data .execution_quotes_iter_on_date_for_symbols( execution_date, - (!self.subscriptions.is_empty()).then_some(&self.subscriptions), + (!unfiltered_minute_stream).then_some(&full_minute_symbols), ) .peekable(); let requires_minute_callbacks = self.strategy.requires_minute_callbacks(); @@ -2913,7 +2920,8 @@ where minute_group.push( minute_quotes .next() - .expect("peeked minute quote must be available"), + .expect("peeked minute quote must be available") + .clone(), ); } let has_specific_schedule = next_schedule_timestamp == Some(minute_timestamp); @@ -2985,7 +2993,10 @@ where crate::strategy::StrategyDecision::default() }; if requires_minute_callbacks { - for "e in &minute_group { + for quote in &minute_group { + if !self.subscriptions.is_empty() && !self.subscriptions.contains("e.symbol) { + continue; + } minute_decision.merge_from(self.strategy.on_minute( &StrategyContext { execution_date, @@ -3098,6 +3109,28 @@ where ProcessEventKind::PostMinute, format!("minute:{minute_timestamp}:post"), )?; + // A scheduled strategy need not subscribe to every + // minute to keep a DAY/GTC limit order alive. Fetch the + // resting symbols once, then resume the actual quote + // clock strictly after the event already processed. + let mut newly_pending = self.broker.open_order_views().into_iter() + .map(|order| order.symbol) + .filter(|symbol| !full_minute_symbols.contains(symbol)) + .collect::>(); + if !newly_pending.is_empty() && self.broker.matching_type_uses_intraday_quotes() { + full_minute_symbols.extend(newly_pending.iter().cloned()); + if self.execution_quote_loader.is_some() { + self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?; + } + drop(minute_quotes); + quote_data = self.data.clone(); + minute_quotes = quote_data.execution_quotes_iter_on_date_for_symbols( + execution_date, (!unfiltered_minute_stream).then_some(&full_minute_symbols), + ).peekable(); + while minute_quotes.peek().is_some_and(|quote| quote.timestamp <= minute_timestamp) { + minute_quotes.next(); + } + } } drop(minute_group); drop(minute_quotes); @@ -5847,6 +5880,60 @@ mod tests { ); } + #[test] + fn scheduled_day_limit_order_loads_later_quotes_without_strategy_minute_subscription() { + struct RestingLimit { quantity: i32 } + impl Strategy for RestingLimit { + fn name(&self) -> &str { "resting-limit" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily("open", ScheduleStage::OnDay) + .with_time_rule(ScheduleTimeRule::physical_time(9, 30))] + } + fn on_scheduled(&mut self, _: &StrategyContext<'_>, _: &ScheduleRule) -> Result { + Ok(StrategyDecision { order_intents: vec![OrderIntent::LimitTargetShares { + symbol: SYMBOL.into(), target_quantity: self.quantity, limit_price: 10.0, reason: "resting-entry".into(), + }], ..StrategyDecision::default() }) + } + } + for partial in [false, true] { + let date = d(2026, 6, 1); + let quote = |hour, minute, price| IntradayExecutionQuote { + date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(), + last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000, + volume_delta: 10_000, amount_delta: price * 10_000.0, trading_phase: None, + }; + let first = quote(9, 30, if partial { 9.8 } else { 10.2 }); + let earlier = quote(9, 29, 9.0); + let later = quote(10, 0, 9.8); + let last = quote(10, 1, 9.8); + let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]); + data.add_execution_quotes(vec![first.clone()]); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig { + initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date), + decision_lag_trading_days: 0, execution_price_field: PriceField::Close, + }).with_execution_quote_loader(move |request| { + captured.lock().unwrap().push((request.start_time, request.end_time)); + Ok(vec![earlier.clone(), first.clone(), later.clone(), last.clone()]) + }); + let result = engine.run().unwrap(); + assert_eq!(result.fills.len(), if partial { 3 } else { 1 }, "resting DAY order must match later actual quotes: {:?}", result.order_events); + assert_eq!(result.fills[0].execution_timestamp, if partial { date.and_hms_opt(9, 30, 0) } else { date.and_hms_opt(10, 0, 0) }); + assert_eq!(result.fills[0].price, 9.8); + assert_eq!(result.fills[0].quantity, 100); + assert_eq!(result.fills.iter().map(|fill| fill.quantity).sum::(), if partial { 300 } else { 100 }); + assert!(result.fills.iter().all(|fill| fill.execution_timestamp >= date.and_hms_opt(9, 30, 0))); + assert_eq!(requests.lock().unwrap().as_slice(), &[(None, None)]); + assert!(!result.order_events.iter().any(|order| order.status == crate::OrderStatus::Expired)); + } + } + #[test] fn scheduled_event_detail_records_actual_time_only_for_timed_rules() { let timed = ScheduleRule::daily("timed", ScheduleStage::OnDay)