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
+52
View File
@@ -423,6 +423,10 @@ struct AlgoExecutionRequest {
}
pub struct BrokerSimulator<C, R> {
historical_etf_open_fallback: bool,
verified_etf_minute_absences: RefCell<BTreeSet<(NaiveDate, String)>>,
runtime_etf_daily_open: Cell<bool>,
deferred_etf_targets: RefCell<crate::etf_execution::DeferredEtfTargets>,
cost_model: C,
rules: R,
board_lot_size: u32,
@@ -461,6 +465,10 @@ pub struct BrokerSimulator<C, R> {
impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self {
Self {
historical_etf_open_fallback: false,
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()),
cost_model,
rules,
board_lot_size: 100,
@@ -503,6 +511,10 @@ impl<C, R> BrokerSimulator<C, R> {
execution_price_field: PriceField,
) -> Self {
Self {
historical_etf_open_fallback: false,
verified_etf_minute_absences: RefCell::new(BTreeSet::new()),
runtime_etf_daily_open: Cell::new(false),
deferred_etf_targets: RefCell::new(Default::default()),
cost_model,
rules,
board_lot_size: 100,
@@ -549,6 +561,40 @@ impl<C, R> BrokerSimulator<C, R> {
self
}
/// Historical stock-pool adapter only. Online runtimes never enable this.
pub fn with_historical_etf_open_fallback(mut self, enabled: bool) -> Self {
self.historical_etf_open_fallback = enabled;
self
}
pub(crate) fn requires_etf_absence_check(&self, data: &DataSet, symbol: &str) -> bool {
self.historical_etf_open_fallback && data.instrument(symbol).is_some_and(|v| v.is_exchange_traded_fund())
}
pub(crate) fn record_complete_etf_minute_query(&self, date: NaiveDate, data: &DataSet, symbols: &[String]) {
for symbol in symbols {
if self.requires_etf_absence_check(data, symbol) && data.execution_quotes_on(date, symbol).is_empty() {
self.verified_etf_minute_absences.borrow_mut().insert((date, symbol.clone()));
}
}
}
pub(crate) fn has_verified_etf_minute_absence(&self, date: NaiveDate, symbol: &str) -> bool {
self.historical_etf_open_fallback && self.verified_etf_minute_absences.borrow().contains(&(date, symbol.to_string()))
}
fn with_etf_daily_open<T>(&self, operation: impl FnOnce() -> Result<T, BacktestError>) -> Result<T, BacktestError> {
if self.liquidity_limit {
return Err(BacktestError::Execution("etf_daily_open_fallback: historical opening depth is unavailable; cannot satisfy liquidity_limit".into()));
}
self.volume_capacity_mode.validate(self.volume_limit, false)
.map_err(|error| BacktestError::Execution(format!("etf_daily_open_fallback: {error}")))?;
let prior = self.runtime_etf_daily_open.replace(true);
let result = operation();
self.runtime_etf_daily_open.set(prior);
result
}
pub fn capacity_audit_summary(&self) -> CapacityAuditSummary {
CapacityAuditSummary { mode: self.volume_capacity_mode, enabled: self.volume_limit,
participation_rate: self.volume_percent, ..Default::default() }
@@ -729,6 +775,7 @@ impl<C, R> BrokerSimulator<C, R> {
}
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
if self.runtime_etf_daily_open.get() { return PriceField::Open; }
if self.is_post_close_fixed_price(date) {
PriceField::Close
} else if self.resting_daily_open_order() {
@@ -910,6 +957,7 @@ where
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot,
) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
if self.is_post_close_fixed_price(date) {
return snapshot.close;
}
@@ -1268,6 +1316,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
if self.is_post_close_fixed_price(snapshot.date) {
return snapshot.close;
}
@@ -1426,6 +1475,7 @@ where
&self,
algo_request: Option<&AlgoExecutionRequest>,
) -> MatchingType {
if self.runtime_etf_daily_open.get() && algo_request.is_none() { return MatchingType::NextBarOpen; }
match algo_request.map(|request| request.style) {
Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap,
Some(AlgoExecutionStyle::Twap) => MatchingType::Twap,
@@ -4082,6 +4132,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
if self.runtime_etf_daily_open.get() { return snapshot.open; }
match (self.execution_price_field, side) {
(PriceField::Last, _) => snapshot.price(PriceField::Last),
(_, OrderSide::Buy) => snapshot.buy_price(self.execution_price_field),
@@ -8058,6 +8109,7 @@ where
}
pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool {
if self.runtime_etf_daily_open.get() { return false; }
if self.resting_daily_open_order() { return true; }
matches!(
self.matching_type,