From ff145300b4e76372264b811b7f1b818996308567 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 16 Jun 2026 00:05:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=89=A7=E8=A1=8C=E4=BB=B7qu?= =?UTF-8?q?ote=E5=A4=9A=E6=97=B6=E9=97=B4=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 8 +- crates/fidc-core/src/engine.rs | 19 ++ .../fidc-core/tests/decision_quote_preload.rs | 239 +++++++++++++++++- 3 files changed, 259 insertions(+), 7 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index a284f5a..a3c5452 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -362,8 +362,7 @@ where symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { - if self.aiquant_rqalpha_execution_rules && self.execution_price_field == PriceField::Last - { + if self.aiquant_rqalpha_execution_rules && self.execution_price_field == PriceField::Last { let start_cursor = self .runtime_intraday_start_time .get() @@ -5395,7 +5394,10 @@ mod tests { ) .expect("process target value"); - assert_eq!(portfolio.position(symbol).map(|pos| pos.quantity), Some(21_400)); + assert_eq!( + portfolio.position(symbol).map(|pos| pos.quantity), + Some(21_400) + ); let order = report.order_events.last().expect("target value order"); assert_eq!(order.requested_quantity, 200); assert_eq!(order.filled_quantity, 200); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 076fdde..d7111c5 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -341,6 +341,8 @@ pub struct BacktestEngine { futures_cost_model: FuturesTransactionCostModel, futures_validation_config: FuturesValidationConfig, execution_quote_loader: Option, + execution_quote_request_cache: + BTreeSet<(NaiveDate, String, Option, Option)>, } impl BacktestEngine { @@ -369,6 +371,7 @@ impl BacktestEngine { futures_cost_model: FuturesTransactionCostModel::default(), futures_validation_config: FuturesValidationConfig::default(), execution_quote_loader: None, + execution_quote_request_cache: BTreeSet::new(), } } @@ -553,12 +556,20 @@ where symbols: &mut BTreeSet, ) -> Result<(), BacktestError> { symbols.retain(|symbol| { + let request_key = (execution_date, symbol.clone(), start_time, end_time); + if self.execution_quote_request_cache.contains(&request_key) { + return false; + } + if start_time.is_some() && end_time.is_none() { + return true; + } !has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time) }); if symbols.is_empty() { return Ok(()); } + let requested_symbols = symbols.iter().cloned().collect::>(); let request = ExecutionQuoteRequest { date: execution_date, start_time, @@ -571,6 +582,14 @@ where .expect("checked execution quote loader") .as_mut()(request)?; self.data.add_execution_quotes(quotes); + for symbol in requested_symbols { + self.execution_quote_request_cache.insert(( + execution_date, + symbol, + start_time, + end_time, + )); + } Ok(()) } diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index bfc77fe..7674ddf 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -1,10 +1,11 @@ -use chrono::{NaiveDate, NaiveTime}; +use chrono::{Duration, NaiveDate, NaiveTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, }; +use std::sync::{Arc, Mutex}; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") @@ -209,9 +210,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() { .map(|symbol| IntradayExecutionQuote { date: request.date, symbol, - timestamp: request - .date - .and_time(t(10, 39, 59)), + timestamp: request.date.and_time(t(10, 39, 59)), last_price: if request.date == second { 11.0 } else { 10.0 }, bid1: if request.date == second { 11.0 } else { 10.0 }, ask1: if request.date == second { 11.0 } else { 10.0 }, @@ -226,3 +225,235 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() { engine.run().expect("backtest should run"); } + +#[derive(Default)] +struct MultiTimeDecisionQuoteReader { + day_count: usize, +} + +impl Strategy for MultiTimeDecisionQuoteReader { + fn name(&self) -> &str { + "multi_time_decision_quote_reader" + } + + fn decision_quote_times(&self) -> Vec { + vec![t(10, 31, 0), t(10, 40, 0)] + } + + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.day_count += 1; + if self.day_count == 1 { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Value { + symbol: "000001.SZ".to_string(), + value: 5_000.0, + reason: "seed_position".to_string(), + }], + ..StrategyDecision::default() + }); + } + + let quote_times = ctx + .data + .execution_quotes_on(ctx.execution_date, "000001.SZ") + .iter() + .map(|quote| quote.timestamp.time()) + .collect::>(); + assert!( + quote_times.contains(&t(10, 30, 59)), + "10:31 decision quote must be loaded" + ); + assert!( + quote_times.contains(&t(10, 39, 59)), + "10:40 decision quote must not be skipped because 10:31 was loaded" + ); + Ok(StrategyDecision::default()) + } +} + +#[test] +fn engine_loads_distinct_decision_quote_times_on_same_day() { + let first = d(2026, 1, 5); + let second = d(2026, 1, 6); + let data = DataSet::from_components( + Vec::new(), + vec![ + DailyMarketSnapshot { + date: first, + symbol: "000001.SZ".to_string(), + timestamp: Some("2026-01-05 15:00:00".to_string()), + 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, + tick_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, + }, + DailyMarketSnapshot { + date: second, + symbol: "000001.SZ".to_string(), + timestamp: Some("2026-01-06 15:00:00".to_string()), + day_open: 10.5, + open: 10.5, + high: 11.2, + low: 10.4, + close: 10.6, + last_price: 10.6, + bid1: 10.6, + ask1: 10.6, + prev_close: 10.0, + volume: 10_000, + tick_volume: 1_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: first, + 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(), + }, + DailyFactorSnapshot { + date: second, + 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: first, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: second, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: first, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1000.0, + prev_close: 990.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: second, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 1000.0, + volume: 1_000_000, + }, + ], + ) + .expect("dataset"); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_matching_type(MatchingType::NextTickLast) + .with_intraday_execution_start_time(t(10, 40, 0)); + let config = BacktestConfig { + initial_cash: 10_000.0, + benchmark_code: "000852.SH".to_string(), + start_date: Some(first), + end_date: Some(second), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }; + let requests = Arc::new(Mutex::new(Vec::<(NaiveDate, NaiveTime)>::new())); + let captured_requests = Arc::clone(&requests); + let mut engine = BacktestEngine::new( + data, + MultiTimeDecisionQuoteReader::default(), + broker, + config, + ) + .with_execution_quote_loader(move |request| { + let start_time = request + .start_time + .expect("decision quote loader request must include start_time"); + captured_requests + .lock() + .expect("request mutex") + .push((request.date, start_time)); + Ok(request + .symbols + .into_iter() + .map(|symbol| IntradayExecutionQuote { + date: request.date, + symbol, + timestamp: request.date.and_time(start_time) - Duration::seconds(1), + 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()) + }); + + engine.run().expect("backtest should run"); + + let requests = requests.lock().expect("request mutex").clone(); + assert!( + requests.contains(&(second, t(10, 31, 0))), + "second-day 10:31 quote request is required" + ); + assert!( + requests.contains(&(second, t(10, 40, 0))), + "second-day 10:40 quote request must not be skipped by earlier quote" + ); +}