From d0ab59669ff83dc8544c1886a5e35059907ad44c Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 21 Jun 2026 01:59:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=B7=B2=E9=A2=84=E8=BD=BD?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=8A=A5=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 27 ++- .../fidc-core/tests/decision_quote_preload.rs | 183 ++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index c8da21a..2630605 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, NaiveDate, NaiveTime}; +use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use serde::Serialize; use thiserror::Error; @@ -561,7 +561,12 @@ where return false; } if start_time.is_some() && end_time.is_none() { - return true; + return !has_execution_quote_near_start_time( + &self.data, + execution_date, + symbol, + start_time.expect("checked start_time"), + ); } !has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time) }); @@ -3348,6 +3353,24 @@ fn has_execution_quote_in_window( }) } +fn has_execution_quote_near_start_time( + data: &DataSet, + date: NaiveDate, + symbol: &str, + start_time: NaiveTime, +) -> bool { + let cursor = date.and_time(start_time); + let Some(latest) = data + .execution_quotes_on(date, symbol) + .iter() + .filter(|quote| quote.timestamp <= cursor) + .max_by_key(|quote| quote.timestamp) + else { + return false; + }; + cursor.signed_duration_since(latest.timestamp) <= Duration::seconds(90) +} + fn decision_has_algo_execution(decision: &StrategyDecision) -> bool { decision.order_intents.iter().any(|intent| { matches!( diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index 646526c..e44af2d 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -228,6 +228,189 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() { engine.run().expect("backtest should run"); } +#[test] +fn engine_reuses_preloaded_decision_quotes_without_loader_call() { + let first = d(2026, 1, 5); + let second = d(2026, 1, 6); + let data = DataSet::from_components_with_actions_and_quotes( + 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, + risk_level_code: None, + }, + 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, + risk_level_code: None, + }, + ], + 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, + }, + ], + Vec::new(), + vec![ + IntradayExecutionQuote { + date: first, + symbol: "000001.SZ".to_string(), + timestamp: first.and_time(t(10, 39, 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()), + }, + IntradayExecutionQuote { + date: second, + symbol: "000001.SZ".to_string(), + timestamp: second.and_time(t(10, 39, 59)), + last_price: 11.0, + bid1: 11.0, + ask1: 11.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .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 loader_calls = Arc::new(Mutex::new(0usize)); + let captured_loader_calls = Arc::clone(&loader_calls); + let mut engine = BacktestEngine::new(data, DecisionQuoteReader::default(), broker, config) + .with_execution_quote_loader(move |_| { + *captured_loader_calls.lock().expect("loader mutex") += 1; + Ok(Vec::new()) + }); + + engine.run().expect("backtest should run"); + assert_eq!( + *loader_calls.lock().expect("loader mutex"), + 0, + "preloaded execution quotes should satisfy decision-time quote requests" + ); +} + #[derive(Default)] struct MultiTimeDecisionQuoteReader { day_count: usize,