复用预计算行情证券范围
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -453,6 +454,8 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel,
|
futures_cost_model: FuturesTransactionCostModel,
|
||||||
futures_validation_config: FuturesValidationConfig,
|
futures_validation_config: FuturesValidationConfig,
|
||||||
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
execution_quote_loader: Option<ExecutionQuoteLoader>,
|
||||||
|
preplanned_decision_quote_symbols_by_date:
|
||||||
|
Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
|
||||||
execution_quote_request_cache:
|
execution_quote_request_cache:
|
||||||
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
||||||
@@ -539,6 +542,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
futures_cost_model: FuturesTransactionCostModel::default(),
|
futures_cost_model: FuturesTransactionCostModel::default(),
|
||||||
futures_validation_config: FuturesValidationConfig::default(),
|
futures_validation_config: FuturesValidationConfig::default(),
|
||||||
execution_quote_loader: None,
|
execution_quote_loader: None,
|
||||||
|
preplanned_decision_quote_symbols_by_date: None,
|
||||||
execution_quote_request_cache: BTreeSet::new(),
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
risk_free_rate_contract: None,
|
risk_free_rate_contract: None,
|
||||||
}
|
}
|
||||||
@@ -563,6 +567,14 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_preplanned_decision_quote_symbols_by_date(
|
||||||
|
mut self,
|
||||||
|
symbols_by_date: Arc<BTreeMap<NaiveDate, BTreeSet<String>>>,
|
||||||
|
) -> Self {
|
||||||
|
self.preplanned_decision_quote_symbols_by_date = Some(symbols_by_date);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
pub fn with_dividend_reinvestment(mut self, enabled: bool) -> Self {
|
||||||
self.dividend_reinvestment = enabled;
|
self.dividend_reinvestment = enabled;
|
||||||
self
|
self
|
||||||
@@ -2520,31 +2532,47 @@ where
|
|||||||
let on_day_open_orders = self.open_order_views();
|
let on_day_open_orders = self.open_order_views();
|
||||||
let decision_quote_times = self.strategy.decision_quote_times();
|
let decision_quote_times = self.strategy.decision_quote_times();
|
||||||
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
if self.execution_quote_loader.is_some() && !decision_quote_times.is_empty() {
|
||||||
let decision_quote_symbols =
|
if let Some(preplanned) = self
|
||||||
self.strategy.decision_quote_symbols(&StrategyContext {
|
.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,
|
execution_date,
|
||||||
decision_date,
|
decision_quote_symbols,
|
||||||
decision_index,
|
&decision_quote_times,
|
||||||
data: &self.data,
|
)?;
|
||||||
portfolio: &portfolio,
|
} else {
|
||||||
futures_account: self.futures_account.as_ref(),
|
let decision_quote_symbols =
|
||||||
open_orders: &on_day_open_orders,
|
self.strategy.decision_quote_symbols(&StrategyContext {
|
||||||
dynamic_universe: self.dynamic_universe.as_ref(),
|
execution_date,
|
||||||
subscriptions: &self.subscriptions,
|
|
||||||
process_events: &process_events,
|
|
||||||
active_process_event: None,
|
|
||||||
active_datetime: stage_datetime(
|
|
||||||
decision_date,
|
decision_date,
|
||||||
default_stage_time(ScheduleStage::OnDay),
|
decision_index,
|
||||||
),
|
data: &self.data,
|
||||||
order_events: result.order_events.as_slice(),
|
portfolio: &portfolio,
|
||||||
fills: result.fills.as_slice(),
|
futures_account: self.futures_account.as_ref(),
|
||||||
})?;
|
open_orders: &on_day_open_orders,
|
||||||
self.ensure_execution_quotes_for_symbols_at_times(
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
||||||
execution_date,
|
subscriptions: &self.subscriptions,
|
||||||
&decision_quote_symbols,
|
process_events: &process_events,
|
||||||
&decision_quote_times,
|
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(
|
self.ensure_execution_quotes_for_portfolio_times(
|
||||||
execution_date,
|
execution_date,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use fidc_core::{
|
|||||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
StrategyDecision,
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -78,23 +79,64 @@ impl Strategy for NoLoaderDecisionQuoteStrategy {
|
|||||||
fn decision_quote_symbols(
|
fn decision_quote_symbols(
|
||||||
&mut self,
|
&mut self,
|
||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
) -> Result<std::collections::BTreeSet<String>, fidc_core::BacktestError> {
|
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||||
*self
|
*self
|
||||||
.symbol_plan_calls
|
.symbol_plan_calls
|
||||||
.lock()
|
.lock()
|
||||||
.expect("symbol plan counter mutex") += 1;
|
.expect("symbol plan counter mutex") += 1;
|
||||||
Ok(std::collections::BTreeSet::new())
|
Ok(BTreeSet::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||||
fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
DataSet::from_components(
|
||||||
let date = d(2026, 1, 5);
|
|
||||||
let data = DataSet::from_components(
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
|
||||||
Vec::new(),
|
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 {
|
vec![BenchmarkSnapshot {
|
||||||
date,
|
date,
|
||||||
benchmark: "000852.SH".to_string(),
|
benchmark: "000852.SH".to_string(),
|
||||||
@@ -104,7 +146,78 @@ fn engine_skips_decision_quote_symbol_plan_without_loader() {
|
|||||||
volume: 1_000_000,
|
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(
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
ChinaAShareCostModel::default(),
|
ChinaAShareCostModel::default(),
|
||||||
ChinaEquityRuleHooks,
|
ChinaEquityRuleHooks,
|
||||||
|
|||||||
Reference in New Issue
Block a user