复用已预载执行报价

This commit is contained in:
boris
2026-06-21 01:59:51 +08:00
parent d264e39285
commit d0ab59669f
2 changed files with 208 additions and 2 deletions
+25 -2
View File
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use chrono::{Datelike, NaiveDate, NaiveTime}; use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
@@ -561,7 +561,12 @@ where
return false; return false;
} }
if start_time.is_some() && end_time.is_none() { 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) !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 { fn decision_has_algo_execution(decision: &StrategyDecision) -> bool {
decision.order_intents.iter().any(|intent| { decision.order_intents.iter().any(|intent| {
matches!( matches!(
@@ -228,6 +228,189 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
engine.run().expect("backtest should run"); 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)] #[derive(Default)]
struct MultiTimeDecisionQuoteReader { struct MultiTimeDecisionQuoteReader {
day_count: usize, day_count: usize,