From 1aa7c286167501f45702b56ad8d4a6f0783d0754 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 09:41:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E9=A2=84=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E8=A1=8C=E6=83=85=E8=AF=81=E5=88=B8=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 74 +++++++--- .../fidc-core/tests/decision_quote_preload.rs | 133 ++++++++++++++++-- 2 files changed, 174 insertions(+), 33 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 6a9df02..5ea1c5a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use serde::{Deserialize, Serialize}; @@ -453,6 +454,8 @@ pub struct BacktestEngine { futures_cost_model: FuturesTransactionCostModel, futures_validation_config: FuturesValidationConfig, execution_quote_loader: Option, + preplanned_decision_quote_symbols_by_date: + Option>>>, execution_quote_request_cache: BTreeSet<(NaiveDate, String, Option, Option)>, risk_free_rate_contract: Option, @@ -539,6 +542,7 @@ impl BacktestEngine { futures_cost_model: FuturesTransactionCostModel::default(), futures_validation_config: FuturesValidationConfig::default(), execution_quote_loader: None, + preplanned_decision_quote_symbols_by_date: None, execution_quote_request_cache: BTreeSet::new(), risk_free_rate_contract: None, } @@ -563,6 +567,14 @@ impl BacktestEngine { self } + pub fn with_preplanned_decision_quote_symbols_by_date( + mut self, + symbols_by_date: Arc>>, + ) -> Self { + self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date); + self + } + pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self { self.dividend_reinvestment = enabled; self @@ -2520,31 +2532,47 @@ where let on_day_open_orders = self.open_order_views(); let decision_quote_times = self.strategy.decision_quote_times(); if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() { - let decision_quote_symbols = - self.strategy.decision_quote_symbols(&StrategyContext { + if let Some(preplanned) = self + .preplanned_decision_quote_symbols_by_date + .as_ref() + .map(Arc::clone) + { + let empty_symbols = BTreeSet::new(); + let decision_quote_symbols = preplanned + .get(&execution_date) + .unwrap_or(&empty_symbols); + self.ensure_execution_quotes_for_symbols_at_times( execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &on_day_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: stage_datetime( + decision_quote_symbols, + &decision_quote_times, + )?; + } else { + let decision_quote_symbols = + self.strategy.decision_quote_symbols(&StrategyContext { + execution_date, decision_date, - default_stage_time(ScheduleStage::OnDay), - ), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - })?; - self.ensure_execution_quotes_for_symbols_at_times( - execution_date, - &decision_quote_symbols, - &decision_quote_times, - )?; + decision_index, + data: &self.data, + portfolio: &portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &on_day_open_orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events: &process_events, + active_process_event: None, + active_datetime: stage_datetime( + decision_date, + default_stage_time(ScheduleStage::OnDay), + ), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), + })?; + self.ensure_execution_quotes_for_symbols_at_times( + execution_date, + &decision_quote_symbols, + &decision_quote_times, + )?; + } } self.ensure_execution_quotes_for_portfolio_times( execution_date, diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index e0b4202..99b4ba6 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -5,6 +5,7 @@ use fidc_core::{ IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, }; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -78,23 +79,64 @@ impl Strategy for NoLoaderDecisionQuoteStrategy { fn decision_quote_symbols( &mut self, _ctx: &StrategyContext<'_>, - ) -> Result, fidc_core::BacktestError> { + ) -> Result, fidc_core::BacktestError> { *self .symbol_plan_calls .lock() .expect("symbol plan counter mutex") += 1; - Ok(std::collections::BTreeSet::new()) + Ok(BTreeSet::new()) } } -#[test] -fn engine_skips_decision_quote_symbol_plan_without_loader() { - let date = d(2026, 1, 5); - let data = DataSet::from_components( - Vec::new(), - Vec::new(), - Vec::new(), +fn single_day_quote_plan_data(date: NaiveDate) -> DataSet { + DataSet::from_components( Vec::new(), + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some(format!("{date} 15:00:00")), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.8, + volume: 10_000, + minute_volume: 1_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.78, + lower_limit: 8.82, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 10.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: Default::default(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }], vec![BenchmarkSnapshot { date, benchmark: "000852.SH".to_string(), @@ -104,7 +146,78 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() { volume: 1_000_000, }], ) - .expect("dataset"); + .expect("dataset") +} + +#[test] +fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() { + let date = d(2026, 1, 5); + let data = single_day_quote_plan_data(date); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::CurrentBarClose); + let config = BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000852.SH".to_string(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }; + let symbol_plan_calls = Arc::new(Mutex::new(0usize)); + let loader_calls = Arc::new(Mutex::new(0usize)); + let strategy = NoLoaderDecisionQuoteStrategy { + symbol_plan_calls: Arc::clone(&symbol_plan_calls), + }; + let captured_loader_calls = Arc::clone(&loader_calls); + let preplanned = Arc::new(BTreeMap::from([( + date, + BTreeSet::from(["000001.SZ".to_string()]), + )])); + let mut engine = BacktestEngine::new(data, strategy, broker, config) + .with_execution_quote_loader(move |request| { + *captured_loader_calls.lock().expect("loader counter mutex") += 1; + Ok(request + .symbols + .into_iter() + .map(|symbol| IntradayExecutionQuote { + date: request.date, + symbol, + timestamp: request.date.and_time(t(10, 17, 59)), + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }) + .collect()) + }) + .with_preplanned_decision_quote_symbols_by_date(preplanned); + + engine.run().expect("backtest should run"); + + assert_eq!( + *symbol_plan_calls.lock().expect("symbol plan counter mutex"), + 0, + "the strategy plan must not be recomputed after a complete plan is supplied" + ); + assert_eq!( + *loader_calls.lock().expect("loader counter mutex"), + 1, + "the supplied symbols must still pass through the normal quote loader" + ); +} + +#[test] +fn engine_skips_decision_quote_symbol_plan_without_loader() { + let date = d(2026, 1, 5); + let data = single_day_quote_plan_data(date); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks,