校验分钟订阅行情覆盖

This commit is contained in:
boris
2026-08-25 03:07:56 +08:00
parent a147c495af
commit 85c9d03b99
+118
View File
@@ -607,7 +607,19 @@ where
.as_mut()
.expect("checked execution quote loader")
.as_mut()(request)?;
let requested_symbol_set = requested_symbols.iter().cloned().collect::<BTreeSet<_>>();
if let Some(quote) = quotes.iter().find(|quote| {
quote.date != execution_date || !requested_symbol_set.contains(&quote.symbol)
}) {
return Err(BacktestError::Execution(format!(
"execution quote loader returned a row outside the request: requested_date={} actual_date={} symbol={}",
execution_date, quote.date, quote.symbol
)));
}
self.data.add_execution_quotes(quotes);
if start_time.is_none() && end_time.is_none() {
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
}
for symbol in requested_symbols {
self.execution_quote_request_cache.insert((
execution_date,
@@ -619,6 +631,48 @@ where
Ok(())
}
fn validate_full_day_execution_quote_coverage(
&self,
execution_date: NaiveDate,
requested_symbols: &[String],
) -> Result<(), BacktestError> {
let mut missing_active = Vec::new();
let mut paused_with_quotes = Vec::new();
let mut missing_daily_market = Vec::new();
for symbol in requested_symbols {
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
continue;
};
let Some(market) = self.data.market(execution_date, symbol) else {
missing_daily_market.push(symbol.clone());
continue;
};
let has_quotes = !self
.data
.execution_quotes_on(execution_date, symbol)
.is_empty();
if market.paused {
if has_quotes {
paused_with_quotes.push(symbol.clone());
}
continue;
}
if market.volume > 0 && !has_quotes {
missing_active.push(symbol.clone());
}
}
if missing_daily_market.is_empty()
&& missing_active.is_empty()
&& paused_with_quotes.is_empty()
{
return Ok(());
}
Err(BacktestError::Execution(format!(
"full-minute subscription coverage mismatch on {}: missing_daily_market={:?}, missing_active_minute_bars={:?}, paused_with_minute_bars={:?}",
execution_date, missing_daily_market, missing_active, paused_with_quotes
)))
}
fn ensure_execution_quotes_for_portfolio_times(
&mut self,
execution_date: NaiveDate,
@@ -4815,6 +4869,70 @@ mod tests {
.expect("backtest run")
}
fn full_day_coverage_engine(
data: DataSet,
date: NaiveDate,
) -> BacktestEngine<BuyWhenDecisionDateStrategy, ChinaAShareCostModel, ChinaEquityRuleHooks>
{
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::MinuteLast)
.with_volume_limit(false)
.with_liquidity_limit(false);
BacktestEngine::new(
data,
BuyWhenDecisionDateStrategy {
decision_date: date,
},
broker,
BacktestConfig {
initial_cash: 100_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::Last,
},
)
}
#[test]
fn full_minute_coverage_rejects_missing_active_bars_but_allows_paused_or_zero_volume() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let active = full_day_coverage_engine(dataset(), first);
let error = active
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
.expect_err("active stock with daily volume requires minute bars");
assert!(
error.to_string().contains("missing_active_minute_bars"),
"{error}"
);
let paused_data = dataset_with(
market_with_state(first, 10.0, 10.0, true, 11.0, 9.0),
market(second, 10.0, 10.0),
candidate_with_state(first, true, false),
candidate(second),
);
full_day_coverage_engine(paused_data, first)
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
.expect("paused stock may have no minute bars");
let zero_volume_data = dataset_with(
market_with_volume(first, 10.0, 10.0, 0),
market(second, 10.0, 10.0),
candidate(first),
candidate(second),
);
full_day_coverage_engine(zero_volume_data, first)
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
.expect("zero-volume stock may have no minute bars");
}
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
run_scheduled_next_open_with_dataset_and_broker(
dataset,