diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 877572a..c53c8eb 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -607,7 +607,19 @@ where .as_mut() .expect("checked execution quote loader") .as_mut()(request)?; + let requested_symbol_set = requested_symbols.iter().cloned().collect::>(); + if let Some(quote) = quotes.iter().find(|quote| { + quote.date != execution_date || !requested_symbol_set.contains("e.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 + { + 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,