//! Executes one frozen pool intent against real broker-simulator state. use super::*; use crate::holding_policy::HoldingLifecycleEvidence; use crate::stock_pool_execution as pool; use rust_decimal::{Decimal, prelude::ToPrimitive}; use chrono::Timelike; #[derive(Debug)] pub(super) struct DeferredStockPoolExecution { date: NaiveDate, contract: Box, buy_only: bool, symbols: BTreeSet, initial_holdings: BTreeSet, } impl BrokerSimulator { pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet { self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect() } pub(crate) fn has_pending_stock_pool_execution(&self) -> bool { !self.deferred_stock_pools.borrow().is_empty() } pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) { self.deferred_stock_pools.borrow_mut().retain(|_, pending| { if pending.date <= date { report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation)); false } else { true } }); } } fn decimal(value: f64, label: &str) -> Result { if !value.is_finite() { return Err(BacktestError::Execution(format!( "stock_pool_nonfinite_{label}" ))); } value .to_string() .parse() .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, ) -> Result, BacktestError> { portfolio .positions() .values() .filter(|p| p.quantity > 0) .map(|p| { Ok(pool::Position { symbol: p.symbol.clone(), quantity: Decimal::from(p.quantity), closable_quantity: Decimal::from(p.sellable_qty(date)), average_cost: decimal(p.average_cost, "position_cost")?, }) }) .collect() } impl BrokerSimulator { pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> { let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time); let mut expired = Vec::new(); for (id, pending) in self.deferred_stock_pools.borrow().iter() { let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M") .map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?; if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); } } for id in expired { if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) { report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation)); } } if self.has_open_orders() || clock.is_none() { return Ok(()); } let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut()); for (id, pending) in pending { let now = clock.expect("clock checked above"); let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M") .map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?; if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) { self.deferred_stock_pools.borrow_mut().insert(id, pending); continue; } let prior_followup = self.runtime_stock_pool_followup.replace(true); let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date)); let prior_created = self.runtime_order_created_date.replace(Some(date)); let order_start = report.order_events.len(); let fill_start = report.fill_events.len(); report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash())); let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract, &mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor, &mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings)); self.runtime_stock_pool_followup.set(prior_followup); self.runtime_decision_date.set(prior_decision); self.runtime_order_created_date.set(prior_created); result?; Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date); } Ok(()) } fn pool_quote_inputs( &self, date: NaiveDate, data: &DataSet, symbols: &BTreeSet, execution_clock: Option, cumulative_conditions: bool, ) -> Result<(Vec, Vec), BacktestError> { let mut unavailable = Vec::new(); let quotes = symbols .iter() .map(|symbol| { let snapshot = data.market(date, symbol).ok_or_else(|| { BacktestError::Execution(format!( "stock_pool_execution_snapshot_missing:{symbol}:{date}" )) })?; let instrument = data.instruments().get(symbol).ok_or_else(|| { BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}")) })?; 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 .runtime_intraday_start_time .get() .or(self.intraday_execution_start_time) .ok_or_else(|| { BacktestError::Execution( "stock_pool_intraday_execution_clock_required".into(), ) })?; let clock = execution_clock .unwrap_or(date.and_time(time)) .max(date.and_time(time)); let quote = data .execution_quotes_on(date, symbol) .iter() .rev() .find(|quote| quote.timestamp <= clock) .ok_or_else(|| { BacktestError::Execution(format!( "stock_pool_execution_quote_missing:{symbol}:{clock}" )) })?; if !quote.last_price.is_finite() || quote.last_price <= 0.0 { return Err(BacktestError::Execution(format!( "stock_pool_execution_quote_invalid:{symbol}:{clock}" ))); } let raw_buy = self .select_quote_reference_price( snapshot, quote, OrderSide::Buy, self.matching_type_for_algo_request(None), ) .ok_or_else(|| { BacktestError::Execution(format!( "stock_pool_buy_reference_missing:{symbol}:{clock}" )) })?; let raw_sell = self .select_quote_reference_price( snapshot, quote, OrderSide::Sell, self.matching_type_for_algo_request(None), ) .ok_or_else(|| { BacktestError::Execution(format!( "stock_pool_sell_reference_missing:{symbol}:{clock}" )) })?; let calibration = self.slippage_calibration(data, snapshot)?; let buy = self.quote_execution_price( snapshot, OrderSide::Buy, raw_buy, None, calibration.as_ref(), )?; let sell = self.quote_execution_price( snapshot, OrderSide::Sell, raw_sell, None, calibration.as_ref(), )?; let totals = if cumulative_conditions { match data.execution_session_totals(symbol, clock) { Ok(totals) => Some(totals), Err(reason) => { unavailable.push(reason); None } } } else { None }; ( quote.last_price, snapshot.prev_close, totals.map(|total| total.0), totals.map(|total| total.1), Some(quote.bid1), Some(quote.ask1), buy, sell, ) } else { let price = snapshot.price(self.effective_execution_price_field(date)); if !price.is_finite() || price <= 0.0 { return Err(BacktestError::Execution(format!( "stock_pool_execution_price_missing:{symbol}:{date}" ))); } // A daily open does not reveal the session's volume/turnover. let completed = self.effective_execution_price_field(date) == PriceField::Close; let totals = if cumulative_conditions && !completed { let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap()); match data.execution_session_totals(symbol, at) { Ok(totals) => Some(totals), Err(reason) => { unavailable.push(reason); None } } } else { None }; let amount = if completed && cumulative_conditions { data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied() .map(|value| decimal(value, "amount")).transpose()? } else { totals.map(|total| total.1) }; ( price, snapshot.prev_close, if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) }, amount, None, None, self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?, self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?, ) }; Ok(pool::MarketSnapshot { symbol: symbol.clone(), last_price: decimal(price, "price")?, prev_close: Some(decimal(prev, "prev_close")?), volume, turnover: amount, bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?, ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?, is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")), instrument_rules: Some(pool::StockPoolInstrumentRules { price_tick: decimal(snapshot.price_tick, "price_tick")?, quantity_step: instrument.order_step_size().into(), minimum_buy_quantity: instrument.minimum_order_quantity().into(), }), buy_sizing_price: Some(decimal(buy_price, "buy_price")?), sell_sizing_price: Some(decimal(sell_price, "sell_price")?), }) }) .collect::, BacktestError>>()?; Ok((quotes, unavailable)) } 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, portfolio: &mut PortfolioState, data: &DataSet, contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap, execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, false, None) } fn process_stock_pool_contract_phase( &self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet, contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap, execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option, commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, buy_only: bool, initial_holdings: Option<&BTreeSet>, ) -> Result<(), BacktestError> { if contract.signal_date > date || contract.frozen_equity < Decimal::ZERO || contract.generation.is_empty() || contract.pool_id.trim().is_empty() { return Err(BacktestError::Execution( "stock_pool_frozen_intent_invalid".into(), )); } if self.matching_type == MatchingType::NextBarOpen && contract.signal_date >= date { return Err(BacktestError::Execution( "stock_pool_next_open_requires_prior_signal".into(), )); } let mut selection = contract.selection.clone(); let mut members = contract.members.clone(); for symbol in &contract.selection.requested_symbols { let instrument = data.instruments().get(symbol).ok_or_else(|| { BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}")) })?; if portfolio.position(symbol).is_none() && let Some(reason) = instrument.dated_market_absence_reason(date) { selection.requested_symbols.retain(|v| v != symbol); selection.normal_trading_symbols.retain(|v| v != symbol); selection.risk_eligible_symbols.retain(|v| v != symbol); selection.final_symbols.retain(|v| v != symbol); members.retain(|v| &v.symbol != symbol); report.diagnostics.push(format!( "stock_pool_market_absence symbol={symbol} date={date} reason={reason}" )); } } let mut scope = selection .requested_symbols .iter() .cloned() .collect::>(); scope.extend(portfolio.positions().keys().cloned()); let before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect()); let official_dates = data.calendar().iter().collect::>(); let initial_positions = pool_positions(portfolio, date)?; let state = portfolio .stock_pool_execution_state(&contract.pool_id) .observe( contract.signal_date, date, &official_dates, &members, &initial_positions, ) .map_err(BacktestError::Execution)?; 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() { self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution { date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions, }); report .diagnostics .push("stock_pool_waiting_for_active_orders no_new_intent=true".into()); return Ok(()); } let mut constraints = contract.constraints.clone(); constraints.execution_date = Some(date); constraints.frozen_positions.clear(); let mut quote_scope = scope.clone(); for symbol in &scope { let paused = data.market(date, symbol).is_some_and(|row| row.paused) || data .candidate(date, symbol) .is_some_and(|row| row.is_paused); if !paused { continue; } quote_scope.remove(symbol); if let Some(position) = portfolio .position(symbol) .filter(|position| position.quantity > 0) { constraints.frozen_positions.insert( symbol.clone(), pool::FrozenStockPoolPosition { trade_date: date, reason: "paused".into(), valuation_price: decimal(position.last_price, "paused_holding_valuation")?, }, ); } else { selection .normal_trading_symbols .retain(|item| item != symbol); selection .risk_eligible_symbols .retain(|item| item != symbol); selection.final_symbols.retain(|item| item != symbol); selection .exclusion_reasons .entry(symbol.clone()) .or_default() .push("paused".into()); } } // 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] { if buy_only && side == pool::OrderSide::Sell { continue; } if side == pool::OrderSide::Buy && self.has_open_orders() && self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy { self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution { date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(), }); report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation)); break; } 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, unavailable) = self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor, crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?; let positions = pool_positions(portfolio, date)?; let execution_state = portfolio .stock_pool_execution_state(&contract.pool_id) .observe( contract.signal_date, date, &official_dates, &members, &positions, ) .map_err(BacktestError::Execution)?; constraints.pending_entry_symbols = execution_state.pending_symbols(); constraints.prior_target_weights = execution_state.last_target_weights.clone(); constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation); constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date); let account = pool::AccountSnapshot { total_equity: contract.frozen_equity, cash: decimal(portfolio.cash(), "cash")?, frozen_cash: Decimal::ZERO, }; constraints.automatic_permissions.clear(); if contract.rule.automatic_trade_protection.enabled() { for symbol in &scope { let position = portfolio.position(symbol).filter(|p| p.quantity > 0); let sold = self .same_day_sold_symbols .borrow() .iter() .rev() .find(|(day, symbols)| **day <= date && symbols.contains(symbol)) .map(|(day, _)| *day); let evidence = 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: sold, }; let permission = contract .rule .automatic_trade_protection .evaluate(symbol, date, &evidence, data.calendar()) .map_err(BacktestError::Execution)?; constraints .automatic_permissions .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 .forbid_same_day_rebuy_after_sell { constraints.same_day_sold_symbols.extend( self.same_day_sold_symbols .borrow() .get(&date) .into_iter() .flatten() .cloned(), ); } constraints.same_day_sold_symbols.extend( before_positions .iter() .filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0)) .cloned(), ); let fee = |symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result { let amount = gross .to_f64() .ok_or("stock_pool_cost_amount_out_of_range")?; decimal( self.cost_model .calculate_for_instrument( date, if side == pool::OrderSide::Buy { OrderSide::Buy } else { OrderSide::Sell }, amount, data.instruments().get(symbol), ) .total(), "fee", ) .map_err(|e| e.to_string()) }; let plan = pool::build_stock_pool_target_plan_with_fee_model( &selection, &members, &contract.rule, &account, &positions, "es, contract.invest_ratio_bps, contract.reserve_cash, &contract.out_of_pool_policy, "full_rebalance", &constraints, &contract.generation, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Some(&fee), ) .map_err(|error| BacktestError::Execution(if !unavailable.is_empty() && (error.contains("requires volume") || error.contains("requires amount")) { format!("{error}; {}", unavailable.join("; ")) } else { error }))?; report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}"))); 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)?; report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget)); let max_positions = constraints .target_holding_count .unwrap_or(selection.final_symbols.len()); for row in plan.rows { if side == pool::OrderSide::Buy && row.side.is_none() { report.diagnostics.push(format!( "stock_pool_decision symbol={} status={} current={} target={} reason={}", row.symbol, row.status, row.current_quantity, row.target_quantity, row.reason )); } 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) .is_none_or(|p| p.quantity == 0) && Self::positive_position_count(portfolio) >= max_positions { report.diagnostics.push(format!( "stock_pool_buy_deferred symbol={} reason=occupied_position_slots", row.symbol )); continue; } let target = row.target_quantity.to_i32().ok_or_else(|| { BacktestError::Execution("stock_pool_target_quantity_out_of_range".into()) })?; 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, data, &row.symbol, target, price.to_f64().ok_or_else(|| { BacktestError::Execution("stock_pool_limit_price_out_of_range".into()) })?, reason, intraday_turnover, execution_cursors, global_execution_cursor, commission_state, report, ) } else { self.process_target_shares( date, portfolio, data, &row.symbol, target, reason, intraday_turnover, execution_cursors, 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) } }