fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets

This commit is contained in:
boris
2026-09-12 10:47:47 +08:00
parent d646ca455d
commit 099759ae67
9 changed files with 650 additions and 15 deletions
+27 -2
View File
@@ -169,6 +169,8 @@ pub enum BacktestTerminalAssetClass {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BacktestTerminalAudit {
#[serde(default, skip_serializing_if = "is_zero_count")]
pub deferred_etf_target_count: usize,
pub status: BacktestTerminalStatus,
pub last_execution_date: Option<NaiveDate>,
pub stock_open_order_count: usize,
@@ -185,6 +187,7 @@ pub struct BacktestTerminalAudit {
impl Default for BacktestTerminalAudit {
fn default() -> Self {
Self {
deferred_etf_target_count: 0,
status: BacktestTerminalStatus::Clean,
last_execution_date: None,
stock_open_order_count: 0,
@@ -200,6 +203,8 @@ impl Default for BacktestTerminalAudit {
}
}
fn is_zero_count(value: &usize) -> bool { *value == 0 }
impl BacktestTerminalAudit {
pub fn is_clean(&self) -> bool {
self.status == BacktestTerminalStatus::Clean
@@ -787,6 +792,17 @@ where
end_time: Option<NaiveTime>,
symbols: &mut BTreeSet<String>,
) -> Result<(), BacktestError> {
// A missing point is not proof of an absent ETF minute dataset. Query
// its complete formal session first; loader/contract failures propagate.
if start_time.is_some() || end_time.is_some() {
let mut etfs = symbols.iter().filter(|symbol| {
self.broker.requires_etf_absence_check(&self.data, symbol)
&& !self.execution_quote_request_cache.contains(&(execution_date, (*symbol).clone(), None, None))
}).cloned().collect::<BTreeSet<_>>();
if !etfs.is_empty() {
self.load_missing_execution_quotes(execution_date, None, None, &mut etfs)?;
}
}
let mut available = BTreeSet::new();
for symbol in symbols.iter() {
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
@@ -813,6 +829,9 @@ where
}
*symbols = available;
symbols.retain(|symbol| {
if (start_time.is_some() || end_time.is_some()) && self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
return false;
}
let request_key = (execution_date, symbol.clone(), start_time, end_time);
if self.execution_quote_request_cache.contains(&request_key) {
return false;
@@ -857,6 +876,7 @@ where
}
self.data.add_execution_quotes(quotes);
if start_time.is_none() && end_time.is_none() {
self.broker.record_complete_etf_minute_query(execution_date, &self.data, &requested_symbols);
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
}
for symbol in requested_symbols {
@@ -893,7 +913,7 @@ where
}
continue;
}
if market.volume > 0 && !has_quotes {
if market.volume > 0 && !has_quotes && !self.broker.has_verified_etf_minute_absence(execution_date, symbol) {
missing_active.push(symbol.clone());
}
}
@@ -1395,6 +1415,7 @@ where
let status = if open_order_count == 0
&& pending_cash_flow_count == 0
&& cash_receivable_count == 0
&& self.broker.pending_etf_target_count() == 0
{
BacktestTerminalStatus::Clean
} else {
@@ -1402,6 +1423,7 @@ where
};
BacktestTerminalAudit {
deferred_etf_target_count: self.broker.pending_etf_target_count(),
status,
last_execution_date,
stock_open_order_count,
@@ -2204,7 +2226,7 @@ where
.and_then(|(_, decision_slot)| *decision_slot);
let Some((decision_index, decision_date)) = decision_slot else {
let mut process_events = Vec::new();
let mut report = BrokerExecutionReport::default();
let mut report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
portfolio.update_prices_with_options(
execution_date,
&self.data,
@@ -2566,6 +2588,9 @@ where
"open_auction:post",
)?;
let deferred_etfs = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
merge_broker_report(&mut report, deferred_etfs);
publish_phase_event(
&mut self.strategy,
&mut self.process_event_bus,