修正执行价quote多时间加载
This commit is contained in:
@@ -362,8 +362,7 @@ where
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
) -> f64 {
|
) -> 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
|
let start_cursor = self
|
||||||
.runtime_intraday_start_time
|
.runtime_intraday_start_time
|
||||||
.get()
|
.get()
|
||||||
@@ -5395,7 +5394,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("process target value");
|
.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");
|
let order = report.order_events.last().expect("target value order");
|
||||||
assert_eq!(order.requested_quantity, 200);
|
assert_eq!(order.requested_quantity, 200);
|
||||||
assert_eq!(order.filled_quantity, 200);
|
assert_eq!(order.filled_quantity, 200);
|
||||||
|
|||||||
@@ -341,6 +341,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>,
|
||||||
|
execution_quote_request_cache:
|
||||||
|
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, C, R> BacktestEngine<S, C, R> {
|
impl<S, C, R> BacktestEngine<S, C, R> {
|
||||||
@@ -369,6 +371,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,
|
||||||
|
execution_quote_request_cache: BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,12 +556,20 @@ where
|
|||||||
symbols: &mut BTreeSet<String>,
|
symbols: &mut BTreeSet<String>,
|
||||||
) -> Result<(), BacktestError> {
|
) -> Result<(), BacktestError> {
|
||||||
symbols.retain(|symbol| {
|
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)
|
!has_execution_quote_in_window(&self.data, execution_date, symbol, start_time, end_time)
|
||||||
});
|
});
|
||||||
if symbols.is_empty() {
|
if symbols.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requested_symbols = symbols.iter().cloned().collect::<Vec<_>>();
|
||||||
let request = ExecutionQuoteRequest {
|
let request = ExecutionQuoteRequest {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
start_time,
|
start_time,
|
||||||
@@ -571,6 +582,14 @@ where
|
|||||||
.expect("checked execution quote loader")
|
.expect("checked execution quote loader")
|
||||||
.as_mut()(request)?;
|
.as_mut()(request)?;
|
||||||
self.data.add_execution_quotes(quotes);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use chrono::{NaiveDate, NaiveTime};
|
use chrono::{Duration, NaiveDate, NaiveTime};
|
||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||||
StrategyDecision,
|
StrategyDecision,
|
||||||
};
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
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 {
|
.map(|symbol| IntradayExecutionQuote {
|
||||||
date: request.date,
|
date: request.date,
|
||||||
symbol,
|
symbol,
|
||||||
timestamp: request
|
timestamp: request.date.and_time(t(10, 39, 59)),
|
||||||
.date
|
|
||||||
.and_time(t(10, 39, 59)),
|
|
||||||
last_price: if request.date == second { 11.0 } else { 10.0 },
|
last_price: if request.date == second { 11.0 } else { 10.0 },
|
||||||
bid1: 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 },
|
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");
|
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<NaiveTime> {
|
||||||
|
vec![t(10, 31, 0), t(10, 40, 0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_day(
|
||||||
|
&mut self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user