From 099759ae67d71b1d6e3da986c58411c47ff04f5a Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 10:47:47 +0800 Subject: [PATCH] fix(stock-pool): execute verified ETF daily fallbacks with frozen next-open targets --- crates/fidc-core/src/broker.rs | 52 +++++ crates/fidc-core/src/broker_stock_pool.rs | 207 +++++++++++++++++- crates/fidc-core/src/engine.rs | 29 ++- crates/fidc-core/src/etf_execution.rs | 128 +++++++++++ crates/fidc-core/src/events.rs | 3 + crates/fidc-core/src/lib.rs | 1 + .../fidc-core/src/platform_expr_strategy.rs | 37 +++- crates/fidc-core/src/platform_stock_pool.rs | 13 ++ .../tests/stock_pool_execution_contract.rs | 195 +++++++++++++++++ 9 files changed, 650 insertions(+), 15 deletions(-) create mode 100644 crates/fidc-core/src/etf_execution.rs diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 4fab055..643973d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -423,6 +423,10 @@ struct AlgoExecutionRequest { } pub struct BrokerSimulator { + historical_etf_open_fallback: bool, + verified_etf_minute_absences: RefCell>, + runtime_etf_daily_open: Cell, + deferred_etf_targets: RefCell, cost_model: C, rules: R, board_lot_size: u32, @@ -461,6 +465,10 @@ pub struct BrokerSimulator { impl BrokerSimulator { 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 BrokerSimulator { 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 BrokerSimulator { 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(&self, operation: impl FnOnce() -> Result) -> Result { + 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 BrokerSimulator { } 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, diff --git a/crates/fidc-core/src/broker_stock_pool.rs b/crates/fidc-core/src/broker_stock_pool.rs index c17bf3c..21b2294 100644 --- a/crates/fidc-core/src/broker_stock_pool.rs +++ b/crates/fidc-core/src/broker_stock_pool.rs @@ -16,6 +16,11 @@ fn decimal(value: f64, label: &str) -> Result { .map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}"))) } +fn etf_activity(report:&mut BrokerExecutionReport,date:NaiveDate,symbol:&str,side:pool::OrderSide,detail:String) { + report.process_events.push(ProcessEvent {date,kind:ProcessEventKind::EtfExecutionFallback,order_id:None, + symbol:Some(symbol.into()),side:Some(if side==pool::OrderSide::Buy {OrderSide::Buy} else {OrderSide::Sell}),detail}); +} + fn pool_positions( portfolio: &PortfolioState, date: NaiveDate, @@ -54,7 +59,13 @@ impl BrokerSimulator { let instrument = data.instruments().get(symbol).ok_or_else(|| { BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}")) })?; - let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self + let fallback = self.pool_etf_fallback_reference(date, data, symbol, execution_clock)?; + let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if let Some(reference) = fallback { + let calibration = self.slippage_calibration(data, snapshot)?; + (reference.price, snapshot.prev_close, None, None, None, None, + self.quote_execution_price(snapshot, OrderSide::Buy, reference.price, None, calibration.as_ref())?, + self.quote_execution_price(snapshot, OrderSide::Sell, reference.price, None, calibration.as_ref())?) + } else if self .matching_type_uses_intraday_quotes() { let time = self @@ -174,6 +185,16 @@ impl BrokerSimulator { .collect() } + fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option) -> Result, BacktestError> { + if !self.matching_type_uses_intraday_quotes() || !self.has_verified_etf_minute_absence(date, symbol) { + return Ok(None); + } + let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time) + .ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: execution clock missing".into()))?; + let at = clock.unwrap_or(date.and_time(time)).max(date.and_time(time)); + crate::etf_execution::reference(data, symbol, at).map(Some) + } + pub(super) fn process_stock_pool_contract( &self, date: NaiveDate, @@ -240,6 +261,8 @@ impl BrokerSimulator { portfolio .set_stock_pool_execution_state(&contract.pool_id, state) .map_err(BacktestError::Execution)?; + let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation); + if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); } if self.has_open_orders() { report .diagnostics @@ -291,7 +314,20 @@ impl BrokerSimulator { .keys() .cloned() .collect::>(); + // All delayed symbols in a generation share immutable configuration. + // Do not duplicate an N-member pool N times in a large mixed pool. + let mut deferred_configuration = None; for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { + let mut fallback_references = BTreeMap::new(); + for symbol in "e_scope { + if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? { + let condition = if side == pool::OrderSide::Buy { &contract.rule.buy_condition } else { &contract.rule.sell_condition }; + if !condition.trim().is_empty() { + return Err(BacktestError::Execution(format!("etf_daily_open_fallback: intraday condition evidence unavailable symbol={symbol} side={side:?}; daily reference is not a minute or tick signal"))); + } + fallback_references.insert(symbol.clone(), reference); + } + } let quotes = self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?; let positions = pool_positions(portfolio, date)?; @@ -340,6 +376,17 @@ impl BrokerSimulator { .insert(symbol.clone(), permission); } } + if side == pool::OrderSide::Buy { + for (symbol, reference) in &fallback_references { + if !reference.immediate { + // The sell leg was queued, not filled. Keep its real + // holdings/slots and do not finance buys with proceeds + // from the following session. + constraints.automatic_permissions.entry(symbol.clone()).or_default() + .sell_denial.get_or_insert("etf_daily_open_deferred"); + } + } + } if self .risk_config .static_rules @@ -401,9 +448,15 @@ impl BrokerSimulator { Some(&fee), ) .map_err(BacktestError::Execution)?; - let updated = execution_state + let mut updated = execution_state .record_plan(contract.signal_date, &contract.generation, &plan) .map_err(BacktestError::Execution)?; + for (symbol, reference) in &fallback_references { + if !reference.immediate && let Some(entry) = updated.entries.get_mut(symbol) { + // The signal only fixes money, not shares at a stale close. + entry.completion_quantity = None; + } + } portfolio .set_stock_pool_execution_state(&contract.pool_id, updated) .map_err(BacktestError::Execution)?; @@ -425,6 +478,26 @@ impl BrokerSimulator { if row.side != Some(side) { continue; } + if let Some(reference) = fallback_references.get(&row.symbol) { + let time = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time).expect("fallback clock validated"); + let at = global_execution_cursor.unwrap_or(date.and_time(time)).max(date.and_time(time)); + if !reference.immediate { + report.diagnostics.push(format!("etf_daily_open_fallback:deferred symbol={} signal_at={at} reference_date={} reference_price={} target_value={} execute_on={:?}", row.symbol, reference.reference_date, reference.price, row.target_value, reference.execute_on)); + let deferred = deferred_configuration.get_or_insert_with(|| ( + std::sync::Arc::new(contract.rule.clone()), std::sync::Arc::new(members.clone()), + )); + let opening_date=reference.execute_on.map(|day|day.to_string()).unwrap_or_else(||"回测区间外(后续日历未加载)".into()); + etf_activity(report,date,&row.symbol,side,format!("ETF 顺延执行:信号 {at},参考 {} 收盘 {},目标金额 {},下一正式开盘日 {opening_date};未生成成交。",reference.reference_date,reference.price,row.target_value)); + self.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget { + pool_id:contract.pool_id.clone(), generation:contract.generation.clone(), symbol:row.symbol.clone(), + signal_date:contract.signal_date, signal_at:at, execute_on:reference.execute_on, + target_value:row.target_value, target_weight_bps:row.target_weight_bps, side, + max_positions, rule:std::sync::Arc::clone(&deferred.0), members:std::sync::Arc::clone(&deferred.1), + reason:row.source_intent.clone().unwrap_or_else(||"stock_pool_target".into()), + }); + continue; + } + } if side == pool::OrderSide::Buy && portfolio .position(&row.symbol) @@ -440,8 +513,14 @@ impl BrokerSimulator { let target = row.target_quantity.to_i32().ok_or_else(|| { BacktestError::Execution("stock_pool_target_quantity_out_of_range".into()) })?; - let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target"); - if let Some(price) = row.limit_price { + let fallback_reason = fallback_references.contains_key(&row.symbol).then(|| format!("{}: etf_daily_open_fallback signal_date={} execution_date={date}", row.source_intent.as_deref().unwrap_or("stock_pool_target"), contract.signal_date)); + let reason = fallback_reason.as_deref().unwrap_or_else(|| row.source_intent.as_deref().unwrap_or("stock_pool_target")); + let first_fill = report.fill_events.len(); + if fallback_references.contains_key(&row.symbol) { + report.diagnostics.push(format!("etf_daily_open_fallback:opening symbol={} signal_date={} execution_date={date}", row.symbol, contract.signal_date)); + etf_activity(report,date,&row.symbol,side,format!("ETF 日线开盘回退:信号日 {},执行日 {date},使用正式日线开盘价;不是分钟成交行情。",contract.signal_date)); + } + let mut execute = || if let Some(price) = row.limit_price { self.process_limit_target_shares( date, portfolio, @@ -457,7 +536,7 @@ impl BrokerSimulator { global_execution_cursor, commission_state, report, - )?; + ) } else { self.process_target_shares( date, @@ -471,10 +550,124 @@ impl BrokerSimulator { global_execution_cursor, commission_state, report, - )?; - } + ) + }; + if fallback_references.contains_key(&row.symbol) { + self.with_etf_daily_open(execute)?; + for fill in &mut report.fill_events[first_fill..] { + fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time())); + fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time())); + } + } else { execute()?; } } } Ok(()) } + + pub(crate) fn pending_etf_target_count(&self) -> usize { + self.deferred_etf_targets.borrow().len() + } + + /// Called at the opening clock, after settlement/corporate actions and + /// auction callbacks. It never sends a stock order or replays a strategy. + pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result { + let mut report = BrokerExecutionReport::default(); + if self.has_open_orders() { + if self.pending_etf_target_count() > 0 { + report.diagnostics.push("etf_daily_open_fallback:waiting_for_active_orders".into()); + } + return Ok(report); + } + let due = self.deferred_etf_targets.borrow_mut().take_due(date); + let dates = data.calendar().iter().collect::>(); + for target in due { + let instrument = data.instrument(&target.symbol).ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: instrument identity missing at execution".into()))?; + if !instrument.is_exchange_traded_fund() { return Err(BacktestError::Execution("etf_daily_open_fallback: instrument identity changed".into())); } + if let Some(reason) = instrument.dated_market_absence_reason(date) { + report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} date={date} reason={reason}", target.symbol)); + continue; + } + let snapshot = data.market(date, &target.symbol).ok_or_else(|| BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_missing symbol={} date={date}", target.symbol)))?; + if !snapshot.open.is_finite() || snapshot.open <= 0.0 { + return Err(BacktestError::Execution(format!("etf_daily_open_fallback: daily_open_invalid symbol={} date={date}", target.symbol))); + } + let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0); + let before_quantity = position.map_or(0, |p| p.quantity); + let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence { + has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()), + last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day), + }, data.calendar()).map_err(BacktestError::Execution)?; + let denial = if target.side == pool::OrderSide::Buy { + permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending")) + } else { permission.sell_denial }; + if let Some(denial) = denial { + report.diagnostics.push(format!("etf_daily_open_fallback:protected symbol={} date={date} reason={denial}", target.symbol)); + etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标受持有保护限制:{denial};未提交委托。")); + continue; + } + if target.side == pool::OrderSide::Buy && before_quantity == 0 && Self::positive_position_count(portfolio) >= target.max_positions { + report.diagnostics.push(format!("etf_daily_open_fallback:blocked symbol={} reason=occupied_position_slots", target.symbol)); + continue; + } + let value = target.target_value.to_f64().ok_or_else(|| BacktestError::Execution("etf_daily_open_fallback: target value out of range".into()))?; + let current_value = snapshot.open * f64::from(before_quantity); + let satisfied = (target.side == pool::OrderSide::Buy && value <= current_value) + || (target.side == pool::OrderSide::Sell && value >= current_value); + let reason = format!("{}: etf_daily_open_fallback signal_at={} execution_at={} target_value={}", target.reason, target.signal_at, date.and_time(crate::etf_execution::opening_time()), target.target_value); + let mut sub = BrokerExecutionReport::default(); + if !satisfied { + let (_, limit) = pool::resolve_stock_pool_order_price(&target.rule, &target.symbol, decimal(snapshot.open, "etf_open")?, target.side, decimal(snapshot.price_tick, "etf_tick")?).map_err(BacktestError::Execution)?; + let intent = match limit { + Some(limit) => OrderIntent::LimitTargetValue { symbol:target.symbol.clone(), target_value:value, limit_price:limit.to_f64().ok_or_else(|| BacktestError::Execution("ETF limit out of range".into()))?, reason:reason.clone() }, + None => OrderIntent::TargetValue { symbol:target.symbol.clone(), target_value:value, reason:reason.clone() }, + }; + let old_time = self.runtime_intraday_start_time.replace(Some(crate::etf_execution::opening_time())); + let old_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date:Some(target.signal_at.date()), submission_time:Some(target.signal_at.time()), accepted_date:date })); + let outcome = self.with_etf_daily_open(|| self.execute_with_event_dates(date, target.signal_date, target.signal_at.date(), portfolio, data, &StrategyDecision { + order_intents:vec![OrderIntent::WithTimeInForce { intent:Box::new(intent), time_in_force:OrderTimeInForce::Day }], ..Default::default() + })); + self.runtime_intraday_start_time.set(old_time); + self.runtime_resting_order_origin.set(old_origin); + sub = outcome?; + } + // The actual open determines the full requested shares. A clipped + // or rejected execution must not be recorded as completed entry. + let order = sub.order_events.iter().rev().find(|order| order.symbol == target.symbol); + let goal_quantity = order.map_or(before_quantity, |order| match order.side { + OrderSide::Buy => before_quantity.saturating_add(order.requested_quantity), + OrderSide::Sell => before_quantity.saturating_sub(order.requested_quantity), + }); + let status = if satisfied || (order.is_none() && !self.has_open_orders()) { "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" } else { "READY" }; + let positions = pool_positions(portfolio, date)?; + let state = portfolio.stock_pool_execution_state(&target.pool_id) + .observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)? + .record_targets(target.signal_date, &target.generation, [crate::stock_pool_state::StockPoolGoalObservation { + symbol:&target.symbol, target_weight_bps:target.target_weight_bps, target_value:target.target_value, + current_quantity:before_quantity.into(), target_quantity:goal_quantity.into(), status, + }]).map_err(BacktestError::Execution)? + .observe(target.signal_date, date, &dates, &target.members, &positions).map_err(BacktestError::Execution)?; + portfolio.set_stock_pool_execution_state(&target.pool_id, state).map_err(BacktestError::Execution)?; + for fill in &mut sub.fill_events { + fill.decision_date.get_or_insert(target.signal_date); + fill.order_created_date.get_or_insert(target.signal_at.date()); + fill.execution_date.get_or_insert(date); + fill.execution_start_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time())); + fill.execution_timestamp.get_or_insert(date.and_time(crate::etf_execution::opening_time())); + } + for order in &mut sub.order_events { + order.decision_date.get_or_insert(target.signal_date); + order.order_created_date.get_or_insert(target.signal_at.date()); + order.execution_date.get_or_insert(date); + } + report.diagnostics.push(reason); + etf_activity(&mut report,date,&target.symbol,target.side,format!("ETF 顺延目标开盘处理:原信号 {},本次 {date} 09:30,冻结目标金额 {},持仓 {before_quantity} → {};按本日开盘价、资金与风控重新定量。",target.signal_at,target.target_value,portfolio.position(&target.symbol).map_or(0,|position|position.quantity))); + report.order_events.extend(sub.order_events); + report.fill_events.extend(sub.fill_events); + report.position_events.extend(sub.position_events); + report.account_events.extend(sub.account_events); + report.process_events.extend(sub.process_events); + report.diagnostics.extend(sub.diagnostics); + } + Ok(report) + } } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index b77a697..07d1a9a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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, 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, symbols: &mut BTreeSet, ) -> 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::>(); + 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, diff --git a/crates/fidc-core/src/etf_execution.rs b/crates/fidc-core/src/etf_execution.rs new file mode 100644 index 0000000..13a9212 --- /dev/null +++ b/crates/fidc-core/src/etf_execution.rs @@ -0,0 +1,128 @@ +//! Historical ETF execution fallback. Never manufactures an intraday bar. +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use rust_decimal::Decimal; +use crate::{BacktestError, DataSet}; + +pub(crate) fn opening_time() -> NaiveTime { + NaiveTime::from_hms_opt(9, 30, 0).expect("valid exchange opening time") +} + +#[derive(Debug, Clone)] +pub(crate) struct EtfFallbackReference { + pub price: f64, + pub reference_date: NaiveDate, + /// None means the next official session is outside the loaded calendar. + /// No natural-day guess or price from beyond the requested run is used. + pub execute_on: Option, + pub immediate: bool, +} + +pub(crate) fn reference(data: &DataSet, symbol: &str, at: NaiveDateTime) -> Result { + let fail = |reason: &str| BacktestError::Execution(format!( + "etf_daily_open_fallback:{reason} symbol={symbol} signal_at={at}" + )); + let instrument = data.instrument(symbol).ok_or_else(|| fail("instrument_identity_missing"))?; + if !instrument.is_exchange_traded_fund() || instrument.listed_at.is_none() { + return Err(fail("verified_etf_identity_required")); + } + if instrument.dated_market_absence_reason(at.date()).is_some() { + return Err(fail("outside_instrument_lifecycle")); + } + if at.time() == opening_time() { + let row = data.market(at.date(), symbol).ok_or_else(|| fail("daily_open_missing"))?; + if !row.open.is_finite() || row.open <= 0.0 { return Err(fail("daily_open_invalid")); } + return Ok(EtfFallbackReference { price: row.open, reference_date: at.date(), execute_on: Some(at.date()), immediate: true }); + } + let previous = data.previous_trading_date(at.date(), 1).ok_or_else(|| fail("previous_official_session_missing"))?; + let close = data.market(previous, symbol).map(|row| row.close).ok_or_else(|| fail("previous_completed_close_missing"))?; + if !close.is_finite() || close <= 0.0 { return Err(fail("previous_completed_close_invalid")); } + Ok(EtfFallbackReference { + price: close, reference_date: previous, immediate: false, + execute_on: if at.time() < opening_time() { Some(at.date()) } else { data.next_trading_date(at.date(), 1) }, + }) +} + +#[derive(Debug, Clone)] +pub(crate) struct DeferredEtfTarget { + pub pool_id: String, + pub generation: String, + pub symbol: String, + pub signal_date: NaiveDate, + pub signal_at: NaiveDateTime, + pub execute_on: Option, + pub target_value: Decimal, + pub target_weight_bps: i32, + pub side: crate::stock_pool_execution::OrderSide, + pub max_positions: usize, + pub rule: std::sync::Arc, + pub members: std::sync::Arc>, + pub reason: String, +} + +/// Owned by one broker/run. Replacing a full pool generation supersedes older +/// queued targets; order of the latest candidate list is retained. +#[derive(Debug, Default)] +pub(crate) struct DeferredEtfTargets { + generations: std::collections::BTreeMap, + rows: Vec, +} + +impl DeferredEtfTargets { + pub fn replace_generation(&mut self, pool_id: &str, generation: &str) -> usize { + if self.generations.get(pool_id).is_some_and(|old| old == generation) { return 0; } + self.generations.insert(pool_id.into(), generation.into()); + let before = self.rows.len(); + self.rows.retain(|row| row.pool_id != pool_id); + before - self.rows.len() + } + pub fn upsert(&mut self, row: DeferredEtfTarget) { + if let Some(existing) = self.rows.iter_mut().find(|v| v.pool_id == row.pool_id && v.symbol == row.symbol) { + *existing = row; + } else { self.rows.push(row); } + } + pub fn take_due(&mut self, date: NaiveDate) -> Vec { + let mut due = Vec::new(); + self.rows.retain(|row| { + if row.execute_on.is_some_and(|day| day <= date) { due.push(row.clone()); false } else { true } + }); + due.sort_by_key(|row| match row.side { crate::stock_pool_execution::OrderSide::Sell => 0, crate::stock_pool_execution::OrderSide::Buy => 1 }); + due + } + pub fn len(&self) -> usize { self.rows.len() } +} + +#[cfg(test)] +mod tests { + use super::*; + fn target(symbol:&str,side:crate::stock_pool_execution::OrderSide,generation:&str)->DeferredEtfTarget { + let date=NaiveDate::from_ymd_opt(2026,1,2).unwrap(); + DeferredEtfTarget {pool_id:"pool".into(),generation:generation.into(),symbol:symbol.into(),signal_date:date,signal_at:date.and_hms_opt(13,0,0).unwrap(),execute_on:NaiveDate::from_ymd_opt(2026,1,5),target_value:1000.into(),target_weight_bps:5000,side,max_positions:2,rule:Default::default(),members:std::sync::Arc::new(vec![]),reason:"fixture".into()} + } + #[test] + fn latest_generation_overwrites_pending_targets_and_preserves_candidate_order() { + use crate::stock_pool_execution::OrderSide::{Buy,Sell}; + let mut queue=DeferredEtfTargets::default(); + queue.replace_generation("pool","v1"); + queue.upsert(target("510300.SH",Buy,"v1")); + queue.upsert(target("159915.SZ",Buy,"v1")); + assert_eq!(queue.replace_generation("pool","v1"),0); + assert_eq!(queue.replace_generation("pool","v2"),2); + queue.upsert(target("560450.SH",Buy,"v2")); + queue.upsert(target("159915.SZ",Sell,"v2")); + queue.upsert(target("510300.SH",Buy,"v2")); + assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,1,2).unwrap()).is_empty()); + let due=queue.take_due(NaiveDate::from_ymd_opt(2026,1,5).unwrap()); + assert_eq!(due.iter().map(|v|v.symbol.as_str()).collect::>(),vec!["159915.SZ","560450.SH","510300.SH"]); + assert!(due.iter().all(|v|v.generation=="v2")); + assert_eq!(queue.len(),0); + } + #[test] + fn no_loaded_next_session_is_not_guessed_from_natural_days() { + let mut queue=DeferredEtfTargets::default(); + let mut item=target("510300.SH",crate::stock_pool_execution::OrderSide::Buy,"v1"); + item.execute_on=None; + queue.upsert(item); + assert!(queue.take_due(NaiveDate::from_ymd_opt(2026,2,1).unwrap()).is_empty()); + assert_eq!(queue.len(),1); + } +} diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index eab1cff..f443f0d 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -318,6 +318,7 @@ pub enum ProcessEventKind { AccountFinanceRepay, AccountManagementFee, SessionCapacityAudit, + EtfExecutionFallback, } impl ProcessEventKind { @@ -364,6 +365,7 @@ impl ProcessEventKind { Self::AccountFinanceRepay => "account_finance_repay", Self::AccountManagementFee => "account_management_fee", Self::SessionCapacityAudit => "session_capacity_audit", + Self::EtfExecutionFallback => "etf_execution_fallback", } } @@ -396,6 +398,7 @@ impl ProcessEventKind { | Self::AccountFinanceRepay | Self::AccountManagementFee | Self::SessionCapacityAudit + | Self::EtfExecutionFallback | Self::Settlement ) } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 3349a08..78e6161 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod pattern_context; pub mod session_events; pub mod factor_events; pub mod execution_capacity; +mod etf_execution; mod execution_schedule; mod factor_event_catalog; pub mod factor_cross_section; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index fa25095..51b6dc8 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -2739,7 +2739,19 @@ impl PlatformExprStrategy { if position.quantity == 0 { continue; } - let mark_price = if self.uses_intraday_execution_quotes() { + let etf_reference_clock = ctx.active_datetime.filter(|at| at.date() == date && at.time() < NaiveTime::from_hms_opt(15,0,0).unwrap()); + let mark_price = if self.config.stock_pool.is_some() + && ctx.data.instrument(&position.symbol).is_some_and(|instrument| instrument.is_exchange_traded_fund()) + && etf_reference_clock.is_some() + { + // The current daily close is not visible while the session is + // running. This is a valuation reference only; the execution + // adapter still requires a successful minute-absence query. + self.scheduled_last_price(ctx, date, &position.symbol).unwrap_or_else(|| { + crate::etf_execution::reference(ctx.data, &position.symbol, etf_reference_clock.unwrap()) + .map(|reference| reference.price).unwrap_or(f64::NAN) + }) + } else if self.uses_intraday_execution_quotes() { self.scheduled_last_price(ctx, date, &position.symbol) .or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last)) .or_else(|| { @@ -2763,6 +2775,9 @@ impl PlatformExprStrategy { .filter(|price| price.is_finite() && *price > 0.0) .unwrap_or(position.last_price) }; + if !mark_price.is_finite() && self.config.stock_pool.is_some() { + return f64::NAN; + } if mark_price.is_finite() && mark_price > 0.0 { total += mark_price * position.quantity as f64; } @@ -10303,6 +10318,13 @@ impl PlatformExprStrategy { let factor_day = ctx.data.daily_snapshot_view(factor_date); let factor_rows = factor_day.factor_rows(); let factor_symbol_ids = factor_day.factor_symbol_ids(); + // Market-cap caches are an optimization, not an implicit universe + // condition. A manual/price-screened ETF need not have share capital. + let requires_total_cap = matches!(self.config.market_cap_field.as_str(), + "market_cap" | "market_cap_bn" | "candidate_market_cap" | "candidate_market_cap_bn") + || self.rank_reuses_market_cap_order(); + let requires_float_cap = matches!(self.config.market_cap_field.as_str(), + "free_float_cap" | "free_float_market_cap" | "free_float_cap_bn"); debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len()); for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) { if self @@ -10371,21 +10393,24 @@ impl PlatformExprStrategy { if reject_from_universe { continue; } - if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { + if requires_total_cap && (factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite()) { continue; } if !self.stock_passes_universe_exclude(candidate, market) { continue; } let market_cap_bn = decision_market_cap_bn(factor); - if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { + if requires_total_cap && (market_cap_bn <= 0.0 || !market_cap_bn.is_finite()) { continue; } + let free_float_cap = decision_free_float_cap_bn(factor); + if requires_float_cap && (!free_float_cap.is_finite() || free_float_cap <= 0.0) { continue; } eligible_symbols[symbol_id as usize] = true; } - for symbol_id in ctx - .data - .factor_symbol_ids_by_market_cap_on(factor_date) + let ordered_ids = if requires_total_cap { + ctx.data.factor_symbol_ids_by_market_cap_on(factor_date) + } else { factor_symbol_ids }; + for symbol_id in ordered_ids .iter() .copied() { diff --git a/crates/fidc-core/src/platform_stock_pool.rs b/crates/fidc-core/src/platform_stock_pool.rs index 052994a..f983d4e 100644 --- a/crates/fidc-core/src/platform_stock_pool.rs +++ b/crates/fidc-core/src/platform_stock_pool.rs @@ -89,6 +89,19 @@ impl PlatformExprStrategy { if self.config.in_skip_window(ctx.decision_date) { return Ok(StrategyDecision::default()); } + let explicit_quote_condition = self.selection_quote_usage != StockFilterQuoteUsage::DailyOnly + || [self.config.buy_filter_expr.as_str(), self.config.stop_loss_expr.as_str(), self.config.take_profit_expr.as_str()] + .into_iter().chain(self.config.position_target_rules.iter().map(|rule|rule.when_expr.as_str())) + .any(|expression|Self::stock_filter_quote_usage_for_expr(expression)!=StockFilterQuoteUsage::DailyOnly); + if explicit_quote_condition && ctx.active_datetime.is_some_and(|at|at.time() DataSet { + let mut parts = data_with_fund_rules(1_000_000, None, true).snapshot_components(); + let previous = NaiveDate::from_ymd_opt(2025,12,31).unwrap(); + for instrument in &mut parts.instruments { instrument.listed_at = Some(NaiveDate::from_ymd_opt(2025,12,1).unwrap()); } + let mut past_market = parts.market.iter().filter(|row| row.date == day(2)).cloned().collect::>(); + for row in &mut past_market { row.date=previous; if row.symbol == code(2) { row.close=5.; row.open=5.; row.high=5.; row.low=5.; row.last_price=5.; } } + parts.market.extend(past_market); + let mut past_factors=parts.factors.iter().filter(|row|row.date==day(2)).cloned().collect::>(); + for row in &mut past_factors {row.date=previous;} + parts.factors.extend(past_factors); + let mut past_candidates=parts.candidates.iter().filter(|row|row.date==day(2)).cloned().collect::>(); + for row in &mut past_candidates {row.date=previous;} + parts.candidates.extend(past_candidates); + for factor in &mut parts.factors { if factor.symbol==code(2) {factor.market_cap_bn=f64::NAN;factor.free_float_cap_bn=f64::NAN;} } + let mut past_benchmark = parts.benchmarks[0].clone(); past_benchmark.date=previous; parts.benchmarks.push(past_benchmark); + for row in &mut parts.market { + if row.symbol==code(2) && row.date>=day(2) { + row.open=if row.date==day(2) {10.} else {4.}; row.day_open=row.open; + row.close=40.; row.last_price=40.; row.high=40.; row.low=row.open; row.prev_close=5.; + } + } + parts.execution_quotes.retain(|row| row.symbol==code(1)); + for quote in &mut parts.execution_quotes { quote.timestamp=quote.date.and_time(time); } + DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap() +} + +struct EtfPoolSignal { at:chrono::NaiveTime, condition:String } +impl fidc_core::strategy::Strategy for EtfPoolSignal { + fn name(&self)->&str {"ETF fallback fixture"} + fn requires_minute_callbacks(&self)->bool {false} + fn decision_quote_times(&self)->Vec {vec![self.at]} + fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result,fidc_core::BacktestError> {Ok(BTreeSet::from([code(1),code(2)]))} + fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result { + if ctx.execution_date!=day(2) {return Ok(StrategyDecision::default());} + let mut intent=contract(day(2),1,true); + intent.selection.final_symbols=vec![code(1),code(2)]; + intent.constraints.target_holding_count=Some(2); + intent.rule.buy_condition=self.condition.clone(); + Ok(decision(intent)) + } +} + +fn run_etf_fallback(time:chrono::NaiveTime,end:NaiveDate,enabled:bool,condition:&str,loader_fails:bool,volume_limit:bool)->Result { + let broker=broker(volume_limit).with_matching_type(MatchingType::MinuteLast) + .with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time) + .with_historical_etf_open_fallback(enabled); + BacktestEngine::new(etf_fallback_fixture(time),EtfPoolSignal{at:time,condition:condition.into()},broker,BacktestConfig{ + initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(end),decision_lag_trading_days:0,execution_price_field:PriceField::Last, + }).with_execution_quote_loader(Box::new(move |_| { + if loader_fails {Err(fidc_core::BacktestError::Execution("fixture_source_unavailable".into()))} else {Ok(vec![])} + })).run() +} + +#[test] +fn historical_etf_open_uses_real_open_without_creating_minute_bars() { + let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(),day(6),true,"",false,false).unwrap(); + let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::>(); + assert_eq!(etf.len(),1,"{:?}",result.fills); + assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(2),10.,1500)); + assert_eq!(etf[0].execution_timestamp,Some(day(2).and_hms_opt(9,30,0).unwrap())); + assert!(etf[0].reason.contains("etf_daily_open_fallback")); + assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2))); +} + +#[test] +fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_open() { + let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(6),true,"",false,false).unwrap(); + let etf=result.fills.iter().filter(|fill| fill.symbol==code(2)).collect::>(); + assert_eq!(etf.len(),1,"{:?}",result.fills); + assert_eq!((etf[0].date,etf[0].price,etf[0].quantity),(day(5),4.,3700)); + assert_eq!(etf[0].execution_timestamp,Some(day(5).and_hms_opt(9,30,0).unwrap())); + assert_eq!(etf[0].order_created_date,Some(day(2))); + assert!(etf[0].reason.contains("2026-01-02 13:00:00")); + assert!(result.fills.iter().any(|fill|fill.symbol==code(1)&&fill.date==day(2))); + assert!(result.terminal_audit.is_clean()); +} + +#[test] +fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() { + let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap(); + assert_eq!(result.terminal_audit.deferred_etf_target_count,1); + assert_eq!(result.terminal_audit.status,fidc_core::BacktestTerminalStatus::CompletedWithPendingState); + assert!(result.order_events.iter().all(|order|order.symbol!=code(2))); + assert!(result.fills.iter().all(|fill|fill.symbol!=code(2))); +} + +#[test] +fn historical_etf_fallback_does_not_waive_source_conditions_or_capacity() { + let at=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(); + assert!(run_etf_fallback(at,day(6),false,"",false,false).is_err()); + assert!(run_etf_fallback(at,day(6),true,"last > 1",false,false).unwrap_err().to_string().contains("condition evidence unavailable")); + assert!(run_etf_fallback(at,day(6),true,"",true,false).unwrap_err().to_string().contains("fixture_source_unavailable")); + assert!(run_etf_fallback(at,day(6),true,"",false,true).unwrap_err().to_string().contains("capacity is missing")); +} + +#[test] +fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() { + let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(); + let intent=contract(day(2),1,true); + let program=StockPoolProgram { + schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members, + allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}), + timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}), + stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(), + }; + let mut config=platform_expr_config_from_value("etf-no-cap-filter","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap(); + config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into(); + config.stock_filter_expr="close > 0".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into(); + config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1)); + config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose; + config.risk_config.trading_constraints.volume_limit_enabled=false; + let result=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config.clone()), + broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true), + BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last}) + .with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap(); + assert!(result.fills.iter().any(|fill|fill.symbol==code(2)),"{:?}",result.equity_curve.iter().map(|row|&row.diagnostics).collect::>()); + assert!(result.fills.iter().any(|fill|fill.symbol==code(1))); + config.stock_filter_expr="last != 0".into(); + let rejected=BacktestEngine::new(etf_fallback_fixture(time),PlatformExprStrategy::new(config), + broker(false).with_matching_type(MatchingType::CurrentBarClose).with_historical_etf_open_fallback(true), + BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(5)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last}) + .with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap_err(); + assert!(rejected.to_string().contains("etf_intraday_condition_evidence_missing"),"{rejected}"); +} + +#[test] +fn etf_signal_budget_does_not_read_the_current_sessions_future_close() { + let run=|future_close:f64| { + let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(); + let mut parts=etf_fallback_fixture(time).snapshot_components(); + for row in &mut parts.market { + if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);} + } + let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap(); + let program=StockPoolProgram{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members, + allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}), + timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()}; + let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap(); + config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into(); + config.stock_filter_expr="true".into();config.selection_limit_expr="2".into();config.selection_candidate_limit_expr="2".into(); + config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));config.intraday_execution_time=Some(time);config.matching_type=MatchingType::CurrentBarClose; + config.risk_config.trading_constraints.volume_limit_enabled=false; + BacktestEngine::new(data,PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true), + BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last}) + .with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap() + }; + let a=run(40.);let b=run(400.); + let budget=|result:&fidc_core::BacktestResult|result.equity_curve.iter().find(|row|row.date==day(5)).unwrap().diagnostics.split(" | ").find(|line|line.starts_with("stock_pool_signal_frozen")).unwrap().to_string(); + assert_eq!(budget(&a),budget(&b)); + assert_eq!(serde_json::to_value(&a.fills).unwrap(),serde_json::to_value(&b.fills).unwrap()); +} + +struct EtfReallocationSignal { protection_days: u32 } +impl fidc_core::strategy::Strategy for EtfReallocationSignal { + fn name(&self)->&str {"deferred ETF sell funding"} + fn requires_minute_callbacks(&self)->bool {false} + fn decision_quote_times(&self)->Vec {vec![chrono::NaiveTime::from_hms_opt(13,0,0).unwrap()]} + fn decision_quote_symbols(&mut self,_:&fidc_core::strategy::StrategyContext<'_>)->Result,fidc_core::BacktestError>{Ok(BTreeSet::from([code(1),code(2)]))} + fn on_day(&mut self,ctx:&fidc_core::strategy::StrategyContext<'_>)->Result { + if ![day(2),day(6)].contains(&ctx.execution_date) {return Ok(Default::default());} + let mut intent=contract(ctx.execution_date,1,false); + intent.rule.automatic_trade_protection.buy_protection_days=self.protection_days; + if ctx.execution_date==day(2) {intent.selection.final_symbols=vec![code(1),code(2)];intent.constraints.target_holding_count=Some(2);} + else {intent.frozen_equity=300000.into();intent.out_of_pool_policy="reduce_to_zero_when_sellable".into();} + Ok(decision(intent)) + } +} + +#[test] +fn deferred_etf_sell_does_not_finance_same_day_stock_topup() { + let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(); + let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:0}, + broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true), + BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last}) + .with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap(); + assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5))); + assert!(result.fills.iter().all(|fill|fill.date!=day(6)),"{:?}",result.fills); + assert!(!result.order_events.iter().any(|order|order.date==day(6)&&order.symbol==code(1)&&order.side==fidc_core::OrderSide::Buy),"{:?}",result.order_events); + assert_eq!(result.terminal_audit.deferred_etf_target_count,1); +} + +#[test] +fn etf_post_buy_protection_starts_on_deferred_fill_day_not_signal_day() { + let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(); + let result=BacktestEngine::new(etf_fallback_fixture(time),EtfReallocationSignal{protection_days:1}, + broker(false).with_matching_type(MatchingType::MinuteLast).with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time).with_historical_etf_open_fallback(true), + BacktestConfig{initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),decision_lag_trading_days:0,execution_price_field:PriceField::Last}) + .with_execution_quote_loader(Box::new(|_|Ok(vec![]))).run().unwrap(); + assert!(result.fills.iter().any(|fill|fill.symbol==code(2)&&fill.date==day(5))); + assert!(result.fills.iter().filter(|fill|fill.symbol==code(2)).all(|fill|fill.side!=fidc_core::OrderSide::Sell)); + // Jan 2 is the signal; actual Jan 5 fill protects Jan 5 and Jan 6. + // Starting the timer on Jan 2 would incorrectly queue an exit on Jan 6. + assert_eq!(result.terminal_audit.deferred_etf_target_count,0); +}