From 13c89e8d59f21df6280d722f369d0ec8d9a6457e Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 18:42:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=80=E7=9B=98=E4=B8=8E?= =?UTF-8?q?=E8=B7=A8=E6=97=A5ETF=E6=89=A7=E8=A1=8C=E6=97=B6=E9=92=9F?= =?UTF-8?q?=E5=8F=8A=E8=B5=84=E9=87=91=E9=98=BB=E6=96=AD=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 94 +- crates/fidc-core/src/engine.rs | 1548 +++++++++++++---- .../tests/stock_pool_execution_contract.rs | 369 ++++ docs/manual-execution-clock-20260914.md | 2 + docs/opening-and-deferred-clock-20260914.md | 39 + 5 files changed, 1684 insertions(+), 368 deletions(-) create mode 100644 docs/opening-and-deferred-clock-20260914.md diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 0c9e191..bad84e9 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -944,9 +944,17 @@ impl BrokerSimulator { } fn new_open_order_submission_time(&self) -> Option { - if self.matching_type == MatchingType::NextBarOpen && !self.runtime_stock_pool_followup.get() { - NaiveTime::from_hms_opt(9, 30, 0) - } else { self.order_origin().1 } + if self.runtime_resting_order_origin.get().is_some() { + return self.order_origin().1; + } + if self.matching_type == MatchingType::NextBarOpen + && !self.runtime_stock_pool_followup.get() + { + let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap(); + Some(self.execution_clock().map_or(open, |clock| clock.max(open))) + } else { + self.execution_clock().or(self.order_origin().1) + } } fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime { @@ -8025,22 +8033,31 @@ where && origin.accepted_date == date && let Some(submitted) = origin.submission_time { - Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted)))) - } else { start_cursor }; - let start_cursor = if algo_request.is_some() { - match (start_cursor, self.execution_clock().map(|time| date.and_time(time))) { - (Some(declared), Some(clock)) => Some(declared.max(clock)), - (start, _) => start, - } - } else { start_cursor }; - let end_cursor = post_close_window.map(|window| { - runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end))) - }).or_else(|| { - algo_request - .and_then(|request| request.end_time) - .or(runtime_end_time) - .map(|end_time| date.and_time(end_time)) - }); + Some(start_cursor.map_or(date.and_time(submitted), |cursor| { + cursor.max(date.and_time(submitted)) + })) + } else { + start_cursor + }; + // A configured session start is not the current submission clock. + // Coarse callbacks and resting-order retries cannot execute backwards + // into an earlier quote, even when they are not algorithm orders. + let start_cursor = match ( + start_cursor, + self.execution_clock().map(|time| date.and_time(time)), + ) { + (Some(declared), Some(clock)) => Some(declared.max(clock)), + (None, Some(clock)) => Some(clock), + (start, None) => start, + }; + let end_cursor = post_close_window + .map(|window| runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))) + .or_else(|| { + algo_request + .and_then(|request| request.end_time) + .or(runtime_end_time) + .map(|end_time| date.and_time(end_time)) + }); let end_cursor = if end_cursor.is_none() && matching_type == MatchingType::CurrentBarClose && self.matching_type_uses_intraday_quotes() @@ -8253,6 +8270,7 @@ where let mut last_timestamp = None; let mut legs = Vec::new(); let mut budget_block_reason = None; + let mut budget_block_timestamp = None; let mut execution_block_reason = None; let mut execution_block_timestamp = None; let mut saw_non_blocked_execution_price = false; @@ -8393,6 +8411,7 @@ where self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?; if !quote_price.is_finite() || quote_price <= 0.0 { budget_block_reason = Some("invalid execution price"); + budget_block_timestamp = Some(execution_at); take_qty = 0; break; } @@ -8411,6 +8430,7 @@ where .is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit)) { budget_block_reason = Some("value budget limit"); + budget_block_timestamp = Some(execution_at); take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -8436,6 +8456,7 @@ where break; } budget_block_reason = Some("insufficient cash after fees"); + budget_block_timestamp = Some(execution_at); take_qty = self.decrement_order_quantity( take_qty, minimum_order_quantity, @@ -8516,6 +8537,16 @@ where unfilled_reason: Some(reason), })); } + if let Some(reason) = budget_block_reason { + return Ok(Some(ExecutionFill { + quantity: 0, + next_cursor: budget_block_timestamp.expect("budget-blocked quote timestamp") + + Duration::seconds(1), + legs: Vec::new(), + liquidity_consumption: Vec::new(), + unfilled_reason: Some(reason), + })); + } return Ok(None); } @@ -8717,6 +8748,31 @@ mod tests { include!("broker_stock_pool_batch_tests.rs"); + #[test] + fn queued_order_retains_the_real_creation_clock_when_retried() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(); + let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap(); + let created = NaiveTime::from_hms_opt(9, 31, 0).unwrap(); + let later = NaiveTime::from_hms_opt(10, 0, 0).unwrap(); + for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] { + let broker = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(matching) + .with_intraday_execution_start_time(open); + broker.runtime_execution_clock.set(Some(created)); + assert_eq!(broker.new_open_order_submission_time(), Some(created)); + broker + .runtime_resting_order_origin + .set(Some(super::RestingOrderOrigin { + created_date: Some(date), + submission_time: Some(created), + accepted_date: date, + })); + broker.runtime_execution_clock.set(Some(later)); + assert_eq!(broker.new_open_order_submission_time(), Some(created)); + } + } + fn test_open_order(order_id: u64) -> OpenOrder { OpenOrder { order_id, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index ed5851e..765ce79 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -902,6 +902,66 @@ where Ok((combined, phase_end)) } + fn finish_opening_callback( + &mut self, + execution_date: NaiveDate, + decision_date: NaiveDate, + decision_index: usize, + clock: NaiveTime, + portfolio: &mut PortfolioState, + result: &mut BacktestResult, + events: &mut Vec, + directives: &mut BrokerExecutionReport, + decision: &mut StrategyDecision, + ) -> Result<(), BacktestError> { + let orders = self.open_order_views(); + decision.merge_from(self.strategy.open_auction(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events: events, + active_process_event: None, + active_datetime: stage_datetime(decision_date, Some(clock)), + order_events: &result.order_events, + fills: &result.fills, + })?); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + events, + execution_date, + ProcessEventKind::OpenAuction, + "open_auction", + CallbackObservation::from_result(result, stage_datetime(execution_date, Some(clock))), + )?; + self.apply_strategy_directives( + execution_date, + decision_date, + decision_index, + portfolio, + events, + decision, + directives, + result, + stage_datetime(execution_date, Some(clock)), + ) + } + /// Execute an existing intent even when this market session has no new /// factor/selection snapshot. Its signal context is never fabricated. fn execute_deferred_session_without_signal( @@ -911,69 +971,256 @@ where ) -> Result { let mut report = BrokerExecutionReport::default(); let pending = std::mem::take(&mut self.deferred_session_decisions); - if pending.is_empty() { return Ok(report); } - let latest_target = pending.iter().rposition(|batch| batch.decision.is_portfolio_target_only()); - let pending = pending.into_iter().enumerate().filter_map(|(index, batch)| { - if batch.decision.is_portfolio_target_only() && Some(index) != latest_target { - result.risk_decisions.extend(batch.decision.risk_decisions); - report.diagnostics.push("unsubmitted_portfolio_target_superseded".into()); - None - } else { Some(batch) } - }).collect::>(); - let clock = self.broker.intraday_execution_start_time().unwrap_or_else(|| match self.broker.matching_type() { - MatchingType::CurrentBarClose if self.broker.execution_price_field() == PriceField::Close => NaiveTime::from_hms_opt(15,0,0).unwrap(), - _ => NaiveTime::from_hms_opt(9,30,0).unwrap(), - }); - let last = pending.last().expect("a latest deferred intent is retained"); - let callback_origin = (last.decision_date, last.decision_index); - self.observe_manual_until(cursor, date.and_time(clock), portfolio, result, events, Some(callback_origin))?; - let mut quote_scope = BTreeSet::new(); + let has_deferred_strategy = !pending.is_empty(); + if pending.is_empty() + && self.broker.pending_etf_target_count() == 0 + && !self.broker.has_open_orders() + { + return Ok(report); + } + let latest_target = pending + .iter() + .rposition(|batch| batch.decision.is_portfolio_target_only()); + let pending = pending + .into_iter() + .enumerate() + .filter_map(|(index, batch)| { + if batch.decision.is_portfolio_target_only() && Some(index) != latest_target { + result.risk_decisions.extend(batch.decision.risk_decisions); + report + .diagnostics + .push("unsubmitted_portfolio_target_superseded".into()); + None + } else { + Some(batch) + } + }) + .collect::>(); + let clock = self + .broker + .intraday_execution_start_time() + .unwrap_or_else(|| match self.broker.matching_type() { + MatchingType::CurrentBarClose + if self.broker.execution_price_field() == PriceField::Close => + { + NaiveTime::from_hms_opt(15, 0, 0).unwrap() + } + _ => NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + }); + let callback_origin = pending + .last() + .map(|last| (last.decision_date, last.decision_index)); + let mut quote_scope = self + .open_order_views() + .into_iter() + .map(|order| order.symbol) + .collect::>(); for batch in &pending { let orders = self.open_order_views(); - quote_scope.extend(execution_quote_symbols_for_decision(&batch.decision, portfolio, &orders)); - self.ensure_execution_quotes_for_decision(date, batch.created_at.date(), portfolio, &orders, &batch.decision, None, None)?; - let mut part = self.broker.execute_coarse_at_clock(date, batch.decision_date, batch.created_at.date(), Some(batch.decision_equity), - portfolio, &self.data, &batch.decision, Some(clock))?; - annotate_broker_report_dates(&mut part, batch.decision_date, batch.created_at.date(), date); - result.risk_decisions.extend(risk_decisions_from_order_events(&part.order_events)); - Self::record_execution_history(result, &mut part, batch.decision_date, date); - let orders = self.open_order_views(); - publish_process_events(&mut self.strategy, &mut self.process_event_bus, date, batch.decision_date, batch.decision_index, - &self.data, portfolio, self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions, - events, &mut part.process_events, CallbackObservation::from_result(result, Some(date.and_time(clock))))?; - merge_broker_report(&mut report, part); + quote_scope.extend(execution_quote_symbols_for_decision( + &batch.decision, + portfolio, + &orders, + )); + self.ensure_execution_quotes_for_decision( + date, + batch.created_at.date(), + portfolio, + &orders, + &batch.decision, + None, + None, + )?; } + if self.execution_quote_loader.is_some() && !quote_scope.is_empty() { + self.load_missing_execution_quotes(date, None, None, &mut quote_scope)?; + } + let mut deferred = + (!pending.is_empty() || self.broker.has_open_orders()).then_some(pending); + let mut etf_clock = (self.broker.pending_etf_target_count() > 0) + .then_some(crate::etf_execution::opening_time()); let quote_data = self.data.clone(); let mut clocks = quote_data.execution_quotes_iter_on_date_for_symbols(date, Some("e_scope)) .map(|quote| quote.timestamp).filter(|at| at.time() > clock) .peekable(); - let end = default_stage_time(ScheduleStage::AfterTrading).unwrap().max(clock); - let mut observed_through = date.and_time(clock); + let end = default_stage_time(ScheduleStage::AfterTrading) + .unwrap() + .max(clock); + let mut observed_through = date.and_hms_opt(0, 0, 0).unwrap(); loop { let expiry = self.broker.next_day_order_expiry(date).map(|time| date.and_time(time)) .filter(|at| *at > observed_through); - let natural = clocks.peek().copied().into_iter().chain(expiry).min(); - let manual = cursor.as_ref().and_then(|value| value.next_observation_at()) - .map(|at| at.with_timezone(&chrono::FixedOffset::east_opt(8*3600).unwrap()).naive_local()) - .filter(|at| at.date() == date && at.time() <= natural.map_or(end, |at| at.time().max(end))); - let Some(at) = natural.into_iter().chain(manual).min() else { break; }; + let natural = clocks + .peek() + .copied() + .into_iter() + .chain(expiry) + .chain(deferred.as_ref().map(|_| date.and_time(clock))) + .chain(etf_clock.map(|time| date.and_time(time))) + .min(); + let manual = cursor + .as_ref() + .and_then(|value| value.next_observation_at()) + .map(|at| { + at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()) + .naive_local() + }) + .filter(|at| { + at.date() == date && at.time() <= natural.map_or(end, |at| at.time().max(end)) + }); + let Some(at) = natural.into_iter().chain(manual).min() else { + break; + }; observed_through = at; - self.observe_manual_until(cursor, at, portfolio, result, events, Some(callback_origin))?; - while clocks.peek() == Some(&at) { clocks.next(); } - let mut part = self.broker.execute_coarse_at_clock(date, callback_origin.0, date, None, - portfolio, &self.data, &StrategyDecision::default(), Some(at.time()))?; - result.risk_decisions.extend(risk_decisions_from_order_events(&part.order_events)); - Self::record_execution_history(result, &mut part, callback_origin.0, date); - let orders = self.open_order_views(); - publish_process_events(&mut self.strategy, &mut self.process_event_bus, date, callback_origin.0, callback_origin.1, - &self.data, portfolio, self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions, - events, &mut part.process_events, CallbackObservation::from_result(result, Some(at)))?; - merge_broker_report(&mut report, part); + self.observe_manual_until(cursor, at, portfolio, result, events, callback_origin)?; + let quote_due = clocks.peek() == Some(&at); + while clocks.peek() == Some(&at) { + clocks.next(); + } + if etf_clock == Some(at.time()) { + etf_clock = None; + let part = self + .broker + .execute_deferred_etf_targets(date, portfolio, &self.data)?; + self.record_deferred_session_report( + part, + at, + callback_origin, + portfolio, + result, + events, + &mut report, + )?; + } + if deferred.is_some() && at.time() == clock { + let pending = deferred.take().unwrap(); + if pending.is_empty() { + let part = self.broker.execute_coarse_at_clock( + date, + date, + date, + None, + portfolio, + &self.data, + &StrategyDecision::default(), + Some(at.time()), + )?; + self.record_deferred_session_report( + part, + at, + callback_origin, + portfolio, + result, + events, + &mut report, + )?; + } + for batch in pending { + let mut part = self.broker.execute_coarse_at_clock( + date, + batch.decision_date, + batch.created_at.date(), + Some(batch.decision_equity), + portfolio, + &self.data, + &batch.decision, + Some(at.time()), + )?; + annotate_broker_report_dates( + &mut part, + batch.decision_date, + batch.created_at.date(), + date, + ); + self.record_deferred_session_report( + part, + at, + Some((batch.decision_date, batch.decision_index)), + portfolio, + result, + events, + &mut report, + )?; + } + } else if deferred.is_none() && (quote_due || expiry == Some(at)) { + let part = self.broker.execute_coarse_at_clock( + date, + callback_origin.map_or(date, |origin| origin.0), + date, + None, + portfolio, + &self.data, + &StrategyDecision::default(), + Some(at.time()), + )?; + self.record_deferred_session_report( + part, + at, + callback_origin, + portfolio, + result, + events, + &mut report, + )?; + } + } + if has_deferred_strategy { + report.diagnostics.push( + "deferred_strategy_session_no_new_signal original_signal_preserved=true".into(), + ); } - report.diagnostics.push("deferred_strategy_session_no_new_signal original_signal_preserved=true".into()); Ok(report) } + fn record_deferred_session_report( + &mut self, + mut part: BrokerExecutionReport, + at: chrono::NaiveDateTime, + origin: Option<(NaiveDate, usize)>, + portfolio: &PortfolioState, + result: &mut BacktestResult, + events: &mut Vec, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + result + .risk_decisions + .extend(risk_decisions_from_order_events(&part.order_events)); + Self::record_execution_history( + result, + &mut part, + origin.map_or(at.date(), |origin| origin.0), + at.date(), + ); + if let Some((decision_date, decision_index)) = origin { + let orders = self.open_order_views(); + publish_process_events( + &mut self.strategy, + &mut self.process_event_bus, + at.date(), + decision_date, + decision_index, + &self.data, + portfolio, + self.futures_account.as_ref(), + &orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + events, + &mut part.process_events, + CallbackObservation::from_result(result, Some(at)), + )?; + } else { + // No new signal context exists. Keep the broker's original dates + // and publish its facts without inventing a strategy callback. + for event in part.process_events.drain(..) { + self.process_event_bus.publish(&event); + events.push(event); + } + } + merge_broker_report(report, part); + Ok(()) + } + fn ensure_execution_quotes_for_decision( &mut self, execution_date: NaiveDate, @@ -2846,12 +3093,23 @@ 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 = self.execute_deferred_session_without_signal(execution_date, &mut portfolio, - &mut manual_cursor, &mut result, &mut process_events)?; - let etf_report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?; - merge_broker_report(&mut report, etf_report); - self.observe_manual_until(&mut manual_cursor, execution_date.and_hms_nano_opt(23, 59, 59, 999_999_999).unwrap(), - &mut portfolio, &mut result, &mut process_events, None)?; + let mut report = self.execute_deferred_session_without_signal( + execution_date, + &mut portfolio, + &mut manual_cursor, + &mut result, + &mut process_events, + )?; + self.observe_manual_until( + &mut manual_cursor, + execution_date + .and_hms_nano_opt(23, 59, 59, 999_999_999) + .unwrap(), + &mut portfolio, + &mut result, + &mut process_events, + None, + )?; portfolio.update_prices_with_options( execution_date, &self.data, @@ -3070,87 +3328,124 @@ where "before_trading:post", CallbackObservation::from_result(&result, Some(execution_date.and_time(before_end_time))), )?; - self.observe_manual_until(&mut manual_cursor, - execution_date.and_time(opening_start_time), - &mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?; - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &pre_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PreOpenAuction, - "open_auction:pre", - CallbackObservation::from_result(&result, Some(execution_date.and_time(opening_start_time))), - )?; - let (mut auction_decision, opening_end_time) = self.collect_lifecycle_schedules( - &scheduler, execution_date, decision_date, decision_index, ScheduleStage::OpenAuction, &coarse_schedule_rules, - opening_start_time.max(before_end_time), self.broker.intraday_execution_start_time(), None, - &mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report, - )?; - auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &pre_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: stage_datetime( + let mut original_minute_clock = + should_run_minute_events(&intraday_schedule_rules, &self.subscriptions); + let opening_times = + scheduled_stage_times(ScheduleStage::OpenAuction, &active_opening_rules, None)?; + let opening_end_time = opening_times + .iter() + .flatten() + .copied() + .chain(std::iter::once(opening_start_time.max(before_end_time))) + .max() + .unwrap(); + if opening_end_time > default_stage_time(ScheduleStage::AfterTrading).unwrap() { + return Err(BacktestError::Execution( + "open_auction schedule overlaps after-trading phase".into(), + )); + } + let interleave_opening = original_minute_clock + || manual_has_fills + || !active_opening_rules.is_empty() + || self.broker.pending_etf_target_count() > 0 + || ((self.broker.has_open_orders() + || self.broker.has_pending_stock_pool_execution()) + && self.broker.drives_resting_quote_clock()); + let mut opening_clock = if interleave_opening { + opening_times + .iter() + .flatten() + .copied() + .chain([opening_start_time, opening_end_time]) + .collect::>() + } else { + BTreeSet::new() + } + .into_iter() + .peekable(); + let mut auction_decision = StrategyDecision::default(); + if !interleave_opening { + self.observe_manual_until( + &mut manual_cursor, + execution_date.and_time(opening_start_time), + &mut portfolio, + &mut result, + &mut process_events, + Some((decision_date, decision_index)), + )?; + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, decision_date, - Some(opening_end_time), - ), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - })?); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &pre_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::OpenAuction, - "open_auction", - CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))), - )?; - self.apply_strategy_directives( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &mut process_events, - &mut auction_decision, - &mut directive_report, - &mut result, - stage_datetime(execution_date, Some(opening_end_time)), - )?; + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &pre_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::PreOpenAuction, + "open_auction:pre", + CallbackObservation::from_result( + &result, + Some(execution_date.and_time(opening_start_time)), + ), + )?; + let (collected, _) = self.collect_lifecycle_schedules( + &scheduler, + execution_date, + decision_date, + decision_index, + ScheduleStage::OpenAuction, + &coarse_schedule_rules, + opening_start_time.max(before_end_time), + self.broker.intraday_execution_start_time(), + None, + &mut portfolio, + &mut manual_cursor, + &mut result, + &mut process_events, + &mut directive_report, + )?; + auction_decision = collected; + self.finish_opening_callback( + execution_date, + decision_date, + decision_index, + opening_end_time, + &mut portfolio, + &mut result, + &mut process_events, + &mut directive_report, + &mut auction_decision, + )?; + original_minute_clock = + should_run_minute_events(&intraday_schedule_rules, &self.subscriptions); + } let mut pending_portfolio: Option = None; let mut pre_day_batches = Vec::new(); let mut pre_day_telemetry = StrategyDecision::default(); let mut all_pre_day = std::mem::take(&mut self.deferred_session_decisions); all_pre_day.extend([ - DeferredSessionDecision { created_at: decision_date.and_time(before_end_time), decision_date, decision_index, decision_equity: decision_total_equity.unwrap_or_else(|| portfolio.total_equity()), decision: before_trading_decision }, - DeferredSessionDecision { created_at: decision_date.and_time(opening_end_time), decision_date, decision_index, decision_equity: decision_total_equity.unwrap_or_else(|| portfolio.total_equity()), decision: auction_decision }, + DeferredSessionDecision { + created_at: decision_date.and_time(before_end_time), + decision_date, + decision_index, + decision_equity: decision_total_equity + .unwrap_or_else(|| portfolio.total_equity()), + decision: before_trading_decision, + }, + DeferredSessionDecision { + created_at: decision_date.and_time(opening_end_time), + decision_date, + decision_index, + decision_equity: decision_total_equity + .unwrap_or_else(|| portfolio.total_equity()), + decision: std::mem::take(&mut auction_decision), + }, ]); for mut batch in all_pre_day { if batch.decision.is_portfolio_target_only() { @@ -3171,11 +3466,16 @@ where pre_day_telemetry.risk_decisions.append(&mut batch.decision.risk_decisions); } } - let original_minute_clock = should_run_minute_events(&intraday_schedule_rules, &self.subscriptions); let mut deferred_etf_time = (self.broker.pending_etf_target_count() > 0) .then_some(crate::etf_execution::opening_time()); let mut deferred_day_time = self.broker.intraday_execution_start_time().or_else(|| { - (original_minute_clock || manual_has_fills || deferred_etf_time.is_some() || pending_portfolio.is_some() || !pre_day_batches.is_empty()).then(|| match self.broker.matching_type() { + (interleave_opening + || original_minute_clock + || manual_has_fills + || deferred_etf_time.is_some() + || pending_portfolio.is_some() + || !pre_day_batches.is_empty()) + .then(|| match self.broker.matching_type() { MatchingType::CurrentBarClose => NaiveTime::from_hms_opt(15, 0, 0).unwrap(), _ => NaiveTime::from_hms_opt(9, 30, 0).unwrap(), }) @@ -3211,24 +3511,29 @@ where &mut report.process_events, CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))), )?; - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_auction_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PostOpenAuction, - "open_auction:post", - CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))), - )?; + if !interleave_opening { + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &post_auction_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::PostOpenAuction, + "open_auction:post", + CallbackObservation::from_result( + &result, + stage_datetime(execution_date, Some(opening_end_time)), + ), + )?; + } Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); @@ -3271,6 +3576,16 @@ where (!unfiltered_minute_stream).then_some(&full_minute_symbols), ) .peekable(); + if !interleave_opening { + // A subscription created by this opening callback starts + // at its real clock, not at an earlier quote in the cache. + while minute_quotes + .peek() + .is_some_and(|quote| quote.timestamp.time() < opening_end_time) + { + minute_quotes.next(); + } + } let requires_minute_callbacks = self.strategy.requires_minute_callbacks(); let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[ ProcessEventKind::PreMinute, @@ -3302,20 +3617,51 @@ where loop { let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp); let next_schedule_timestamp = minute_schedule_timestamps.peek().copied(); - let next_expiry_timestamp = self.broker.next_day_order_expiry(execution_date) + let next_opening_timestamp = opening_clock + .peek() + .map(|time| execution_date.and_time(*time)); + let next_expiry_timestamp = self + .broker + .next_day_order_expiry(execution_date) .map(|time| execution_date.and_time(time)) .filter(|time| last_minute_timestamp.is_none_or(|last| last < *time)); let next_natural_timestamp = next_minute_event_timestamp( - next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp), - next_minute_event_timestamp(next_expiry_timestamp, - next_minute_event_timestamp(deferred_day_time.map(|time| execution_date.and_time(time)), - deferred_etf_time.map(|time|execution_date.and_time(time)))), - ); - let manual_phase_end = default_stage_time(ScheduleStage::AfterTrading).into_iter() - .chain(self.broker.post_close_execution_quote_window_for_order(execution_date, execution_date, last_execution_time).map(|(_, end)| end)) - .chain(next_natural_timestamp.map(|time| time.time())).max().expect("after-trading clock"); - let next_manual_timestamp = manual_cursor.as_ref().and_then(|cursor| cursor.next_observation_at()) - .map(|at| at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).naive_local()) + next_minute_event_timestamp( + next_quote_timestamp, + next_minute_event_timestamp( + next_schedule_timestamp, + next_opening_timestamp, + ), + ), + next_minute_event_timestamp( + next_expiry_timestamp, + next_minute_event_timestamp( + deferred_day_time.map(|time| execution_date.and_time(time)), + deferred_etf_time.map(|time| execution_date.and_time(time)), + ), + ), + ); + let manual_phase_end = default_stage_time(ScheduleStage::AfterTrading) + .into_iter() + .chain( + self.broker + .post_close_execution_quote_window_for_order( + execution_date, + execution_date, + last_execution_time, + ) + .map(|(_, end)| end), + ) + .chain(next_natural_timestamp.map(|time| time.time())) + .max() + .expect("after-trading clock"); + let next_manual_timestamp = manual_cursor + .as_ref() + .and_then(|cursor| cursor.next_observation_at()) + .map(|at| { + at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()) + .naive_local() + }) .filter(|at| at.date() == execution_date && at.time() <= manual_phase_end); let Some(minute_timestamp) = next_minute_event_timestamp(next_natural_timestamp, next_manual_timestamp) else { @@ -3357,8 +3703,139 @@ where )?; merge_broker_report(&mut report, batch); } - Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); - Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + Self::record_execution_history( + &mut result, + &mut report, + decision_date, + execution_date, + ); + Self::record_execution_history( + &mut result, + &mut directive_report, + decision_date, + execution_date, + ); + if next_opening_timestamp == Some(minute_timestamp) { + opening_clock.next(); + let orders = self.open_order_views(); + if minute_time == opening_start_time { + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::PreOpenAuction, + "open_auction:pre", + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + } + let mut due_rules = Vec::new(); + for rule in &active_opening_rules { + if scheduled_stage_times( + ScheduleStage::OpenAuction, + std::slice::from_ref(rule), + None, + )? + .contains(&Some(minute_time)) + { + due_rules.push(rule.clone()); + } + } + let (scheduled, _) = self.collect_lifecycle_schedules( + &scheduler, + execution_date, + decision_date, + decision_index, + ScheduleStage::OpenAuction, + &due_rules, + minute_time, + None, + None, + &mut portfolio, + &mut manual_cursor, + &mut result, + &mut process_events, + &mut directive_report, + )?; + auction_decision.merge_from(scheduled); + if minute_time == opening_end_time { + self.finish_opening_callback( + execution_date, + decision_date, + decision_index, + minute_time, + &mut portfolio, + &mut result, + &mut process_events, + &mut directive_report, + &mut auction_decision, + )?; + let mut batch = DeferredSessionDecision { + created_at: decision_date.and_time(minute_time), + decision_date, + decision_index, + decision_equity: decision_total_equity + .unwrap_or_else(|| portfolio.total_equity()), + decision: std::mem::take(&mut auction_decision), + }; + if batch.decision.is_portfolio_target_only() { + if let Some(mut previous) = pending_portfolio.take() { + batch.decision.notes.splice(0..0, previous.decision.notes); + batch + .decision + .diagnostics + .splice(0..0, previous.decision.diagnostics); + batch + .decision + .diagnostics + .push("unsubmitted_portfolio_target_superseded".into()); + decision + .risk_decisions + .append(&mut previous.decision.risk_decisions); + } + pending_portfolio = Some(batch); + } else if batch.decision.rebalance + || !batch.decision.order_intents.is_empty() + || !batch.decision.exit_symbols.is_empty() + { + deferred_auction.get_or_insert_with(Vec::new).push(batch); + } else { + decision.notes.append(&mut batch.decision.notes); + decision.diagnostics.append(&mut batch.decision.diagnostics); + decision + .risk_decisions + .append(&mut batch.decision.risk_decisions); + } + let orders = self.open_order_views(); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::PostOpenAuction, + "open_auction:post", + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + } + } if deferred_day_time == Some(minute_time) { deferred_day_time = None; let mut before_batches = deferred_auction.take().unwrap_or_default(); @@ -3396,194 +3873,209 @@ where Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); } - if (minute_group.is_empty() || (!requires_minute_callbacks - && !has_minute_process_listeners)) + let skip_minute_execution = (minute_group.is_empty() + || (!requires_minute_callbacks && !has_minute_process_listeners)) && !schedule_candidate && !self.has_open_orders() - && !self.broker.has_pending_stock_pool_execution() - { - continue; - } - let minute_open_orders = self.open_order_views(); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &minute_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PreMinute, - format!("minute:{minute_timestamp}:pre"), - CallbackObservation::from_result(&result, Some(minute_timestamp)), - )?; - let mut minute_decision = if schedule_candidate { - let event_rules = if has_specific_schedule { - intraday_schedule_rules.as_slice() - } else { - minute_all_time_rules.as_slice() - }; - let mut scheduled = StrategyDecision::default(); - for stage in [ - ScheduleStage::OnDay, - ScheduleStage::Bar, - ScheduleStage::Minute, - ] { - scheduled.merge_from(collect_scheduled_decisions( - &mut self.strategy, - &scheduler, - execution_date, - stage, - event_rules, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &minute_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut self.process_event_bus, - Some(minute_time), - result.order_events.as_slice(), - result.fills.as_slice(), - Some(minute_time), - )?); - } - scheduled - } else { - crate::strategy::StrategyDecision::default() - }; - if requires_minute_callbacks && (original_minute_clock || !self.subscriptions.is_empty()) { - for quote in &minute_group { - if !self.subscriptions.is_empty() && !self.subscriptions.contains("e.symbol) { - continue; - } - minute_decision.merge_from(self.strategy.on_minute( - &StrategyContext { - execution_date, - decision_date, - decision_index, - data: &self.data, - portfolio: &portfolio, - futures_account: self.futures_account.as_ref(), - open_orders: &minute_open_orders, - dynamic_universe: self.dynamic_universe.as_ref(), - subscriptions: &self.subscriptions, - process_events: &process_events, - active_process_event: None, - active_datetime: Some(minute_timestamp), - order_events: result.order_events.as_slice(), - fills: result.fills.as_slice(), - }, - quote, - )?); - } - } - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &minute_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::Minute, - format!("minute:{minute_timestamp}"), - CallbackObservation::from_result(&result, Some(minute_timestamp)), - )?; - self.apply_strategy_directives( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &mut process_events, - &mut minute_decision, - &mut directive_report, - &mut result, - Some(minute_timestamp), - )?; - let pre_minute_execution_orders = self.open_order_views(); - self.ensure_execution_quotes_for_decision( - execution_date, - decision_date, - &portfolio, - &pre_minute_execution_orders, - &minute_decision, - Some(minute_time), - Some(minute_time), - )?; - let mut minute_report = self - .broker - .execute_between_with_event_dates_and_decision_equity( + && !self.broker.has_pending_stock_pool_execution(); + if !skip_minute_execution { + let minute_open_orders = self.open_order_views(); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, execution_date, decision_date, - decision_date, - decision_total_equity, - &mut portfolio, + decision_index, &self.data, + &portfolio, + self.futures_account.as_ref(), + &minute_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::PreMinute, + format!("minute:{minute_timestamp}:pre"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + let mut minute_decision = if schedule_candidate { + let event_rules = if has_specific_schedule { + intraday_schedule_rules.as_slice() + } else { + minute_all_time_rules.as_slice() + }; + let mut scheduled = StrategyDecision::default(); + for stage in [ + ScheduleStage::OnDay, + ScheduleStage::Bar, + ScheduleStage::Minute, + ] { + scheduled.merge_from(collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + stage, + event_rules, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &minute_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + &mut self.process_event_bus, + Some(minute_time), + result.order_events.as_slice(), + result.fills.as_slice(), + Some(minute_time), + )?); + } + scheduled + } else { + crate::strategy::StrategyDecision::default() + }; + if requires_minute_callbacks + && (original_minute_clock || !self.subscriptions.is_empty()) + { + for quote in &minute_group { + if !self.subscriptions.is_empty() + && !self.subscriptions.contains("e.symbol) + { + continue; + } + minute_decision.merge_from(self.strategy.on_minute( + &StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + futures_account: self.futures_account.as_ref(), + open_orders: &minute_open_orders, + dynamic_universe: self.dynamic_universe.as_ref(), + subscriptions: &self.subscriptions, + process_events: &process_events, + active_process_event: None, + active_datetime: Some(minute_timestamp), + order_events: result.order_events.as_slice(), + fills: result.fills.as_slice(), + }, + quote, + )?); + } + } + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &minute_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::Minute, + format!("minute:{minute_timestamp}"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + self.apply_strategy_directives( + execution_date, + decision_date, + decision_index, + &mut portfolio, + &mut process_events, + &mut minute_decision, + &mut directive_report, + &mut result, + Some(minute_timestamp), + )?; + let pre_minute_execution_orders = self.open_order_views(); + self.ensure_execution_quotes_for_decision( + execution_date, + decision_date, + &portfolio, + &pre_minute_execution_orders, &minute_decision, Some(minute_time), Some(minute_time), )?; - Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); - Self::record_execution_history(&mut result, &mut minute_report, decision_date, execution_date); - let post_minute_open_orders = self.open_order_views(); - publish_process_events( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_minute_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut minute_report.process_events, - CallbackObservation::from_result(&result, Some(minute_timestamp)), - )?; - merge_broker_report(&mut report, minute_report); - decision.notes.append(&mut minute_decision.notes); - decision - .diagnostics - .append(&mut minute_decision.diagnostics); - decision - .risk_decisions - .append(&mut minute_decision.risk_decisions); - publish_phase_event( - &mut self.strategy, - &mut self.process_event_bus, - execution_date, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_minute_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - execution_date, - ProcessEventKind::PostMinute, - format!("minute:{minute_timestamp}:post"), - CallbackObservation::from_result(&result, Some(minute_timestamp)), - )?; + let mut minute_report = self + .broker + .execute_between_with_event_dates_and_decision_equity( + execution_date, + decision_date, + decision_date, + decision_total_equity, + &mut portfolio, + &self.data, + &minute_decision, + Some(minute_time), + Some(minute_time), + )?; + Self::record_execution_history( + &mut result, + &mut directive_report, + decision_date, + execution_date, + ); + Self::record_execution_history( + &mut result, + &mut minute_report, + decision_date, + execution_date, + ); + let post_minute_open_orders = self.open_order_views(); + publish_process_events( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &post_minute_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + &mut minute_report.process_events, + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + merge_broker_report(&mut report, minute_report); + decision.notes.append(&mut minute_decision.notes); + decision + .diagnostics + .append(&mut minute_decision.diagnostics); + decision + .risk_decisions + .append(&mut minute_decision.risk_decisions); + publish_phase_event( + &mut self.strategy, + &mut self.process_event_bus, + execution_date, + decision_date, + decision_index, + &self.data, + &portfolio, + self.futures_account.as_ref(), + &post_minute_open_orders, + self.dynamic_universe.as_ref(), + &self.subscriptions, + &mut process_events, + execution_date, + ProcessEventKind::PostMinute, + format!("minute:{minute_timestamp}:post"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; + } + // Opening callbacks can also change subscriptions. Refresh + // the same quote cursor even when this clock has no bar. // A scheduled strategy need not subscribe to every // minute to keep a DAY/GTC limit order alive. Fetch the // resting symbols once, then resume the actual quote @@ -3594,7 +4086,12 @@ where .chain(self.subscriptions.iter().cloned()) .filter(|symbol| !full_minute_symbols.contains(symbol)) .collect::>(); - if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() { + if !newly_pending.is_empty() + && (self.broker.drives_resting_quote_clock() + || newly_pending + .iter() + .any(|symbol| self.subscriptions.contains(symbol))) + { full_minute_symbols.extend(newly_pending.iter().cloned()); if self.execution_quote_loader.is_some() { // A post-close order without a minute subscription @@ -5572,6 +6069,341 @@ mod tests { assert!((result.metrics.average_daily_turnover - 6000. / 9991.).abs() < 1e-12); } + #[test] + fn late_opening_callback_is_interleaved_with_earlier_quotes_and_manual_observations() { + struct Probe { + seen: Rc>>, + } + impl Probe { + fn record(&self, label: &str, ctx: &StrategyContext<'_>) { + self.seen.borrow_mut().push(( + label.into(), + ctx.current_time().unwrap(), + ctx.portfolio + .position(SYMBOL) + .map_or(0, |position| position.quantity), + )); + } + } + impl Strategy for Probe { + fn name(&self) -> &str { + "late-opening-with-earlier-quotes" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("market-open", ScheduleStage::OpenAuction) + .with_time_rule(ScheduleTimeRule::market_open(0, 0)), + ] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + self.record("opening-rule", ctx); + Ok(Default::default()) + } + fn open_auction( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.record("opening-callback", ctx); + Ok(Default::default()) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(Default::default()) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + _: &IntradayExecutionQuote, + ) -> Result { + self.record("quote", ctx); + Ok(Default::default()) + } + } + let date = d(2026, 6, 1); + let data = clock_probe_data(date, &[(9, 30, 10.), (9, 32, 10.)]); + let seen = Rc::new(RefCell::new(vec![])); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false); + let config = BacktestConfig { + initial_cash: 10000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }; + let mut engine = BacktestEngine::new(data, Probe { seen: seen.clone() }, broker, config) + .with_observed_manual_executions(observed_manual_replay(date, &[(9, 31)])) + .unwrap(); + engine.run().unwrap(); + assert_eq!( + *seen.borrow(), + vec![ + ( + "quote".into(), + NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + 0 + ), + ( + "opening-rule".into(), + NaiveTime::from_hms_opt(9, 31, 0).unwrap(), + 100 + ), + ( + "opening-callback".into(), + NaiveTime::from_hms_opt(9, 31, 0).unwrap(), + 100 + ), + ( + "quote".into(), + NaiveTime::from_hms_opt(9, 32, 0).unwrap(), + 100 + ), + ] + ); + } + + #[test] + fn opening_orders_are_submitted_once_at_their_real_clock_even_with_an_earlier_execution_window() + { + struct BuyAtOpen { + subscribe: bool, + seen: Rc>>, + } + impl Strategy for BuyAtOpen { + fn name(&self) -> &str { + "opening-order-clock" + } + fn initial_subscriptions(&self) -> BTreeSet { + if self.subscribe { + [SYMBOL.to_string()].into() + } else { + BTreeSet::new() + } + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("buy", ScheduleStage::OpenAuction) + .with_time_rule(ScheduleTimeRule::market_open(0, 0)), + ] + } + fn on_scheduled( + &mut self, + _: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "opening".into(), + }], + ..Default::default() + }) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(Default::default()) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + _: &IntradayExecutionQuote, + ) -> Result { + self.seen.borrow_mut().push(( + ctx.current_time().unwrap(), + ctx.portfolio + .position(SYMBOL) + .map_or(0, |position| position.quantity), + )); + Ok(Default::default()) + } + } + let date = d(2026, 6, 1); + for subscribe in [false, true] { + for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] { + let field = if matching == MatchingType::NextBarOpen { + PriceField::Open + } else { + PriceField::Last + }; + let seen = Rc::new(RefCell::new(vec![])); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + field, + ) + .with_matching_type(matching) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); + let config = BacktestConfig { + initial_cash: 10000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: field, + }; + let result = BacktestEngine::new( + clock_probe_data(date, &[(9, 30, 10.), (9, 32, 10.)]), + BuyAtOpen { + subscribe, + seen: seen.clone(), + }, + broker, + config, + ) + .run() + .unwrap(); + assert_eq!( + *seen.borrow(), + if subscribe { + vec![ + (NaiveTime::from_hms_opt(9, 30, 0).unwrap(), 0), + (NaiveTime::from_hms_opt(9, 32, 0).unwrap(), 100), + ] + } else { + vec![] + }, + "{matching:?} subscribe={subscribe}" + ); + assert_eq!(result.fills.len(), 1, "{matching:?}: {:?}", result.fills); + assert_eq!( + ( + result.fills[0].quantity, + result.fills[0].price, + result.fills[0].execution_timestamp + ), + (100, 10., Some(date.and_hms_opt(9, 31, 0).unwrap())), + "{matching:?}" + ); + } + } + } + + #[test] + fn opening_subscription_is_loaded_without_replaying_quotes_before_its_activation() { + struct SubscribeAtOpen { + timed_minute: bool, + seen: Rc>>, + } + impl Strategy for SubscribeAtOpen { + fn name(&self) -> &str { + "opening-subscription-clock" + } + fn schedule_rules(&self) -> Vec { + let mut rules = vec![ + ScheduleRule::daily("open", ScheduleStage::OpenAuction) + .with_time_rule(ScheduleTimeRule::market_open(0, 0)), + ]; + if self.timed_minute { + rules.push( + ScheduleRule::daily("later", ScheduleStage::Minute) + .with_time_rule(ScheduleTimeRule::physical_time(10, 0)), + ); + } + rules + } + fn on_scheduled( + &mut self, + _: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(Default::default()) + } + fn open_auction( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Subscribe { + symbols: [SYMBOL.to_string()].into(), + reason: "open subscription".into(), + }], + ..Default::default() + }) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(Default::default()) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + _: &IntradayExecutionQuote, + ) -> Result { + self.seen.borrow_mut().push(ctx.current_time().unwrap()); + Ok(Default::default()) + } + } + let date = d(2026, 6, 1); + for timed_minute in [false, true] { + let seen = Rc::new(RefCell::new(vec![])); + let quotes = clock_probe_data(date, &[(9, 30, 10.), (9, 32, 10.)]) + .execution_quotes_on(date, SYMBOL) + .to_vec(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let captured = calls.clone(); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false); + let config = BacktestConfig { + initial_cash: 10000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }; + BacktestEngine::new( + clock_probe_data(date, &[]), + SubscribeAtOpen { + timed_minute, + seen: seen.clone(), + }, + broker, + config, + ) + .with_execution_quote_loader(move |_| { + captured.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(quotes.clone()) + }) + .run() + .unwrap(); + assert_eq!( + *seen.borrow(), + vec![NaiveTime::from_hms_opt(9, 32, 0).unwrap()], + "timed={timed_minute}" + ); + assert!(calls.load(std::sync::atomic::Ordering::SeqCst) > 0); + } + } + #[test] fn opening_and_late_lifecycle_schedules_do_not_read_past_or_future_manual_state() { struct Probe { seen: Rc>> } @@ -5725,20 +6557,38 @@ mod tests { } #[test] - fn opening_schedule_cannot_advance_past_the_intraday_phase() { + fn opening_schedule_cannot_cross_the_after_trading_phase() { struct InvalidOpening; impl Strategy for InvalidOpening { fn name(&self) -> &str { "invalid-opening" } fn requires_minute_callbacks(&self) -> bool { false } fn schedule_rules(&self) -> Vec { - vec![ScheduleRule::daily("too-late", ScheduleStage::OpenAuction).with_time_rule(ScheduleTimeRule::physical_time(10,0))] + vec![ + ScheduleRule::daily("too-late", ScheduleStage::OpenAuction) + .with_time_rule(ScheduleTimeRule::physical_time(16, 0)), + ] } } - let date = d(2026,6,1); - let broker = BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open) - .with_matching_type(MatchingType::NextBarOpen).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap()); - let config = BacktestConfig { initial_cash:10000., benchmark_code:"000852.SH".into(), start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0, execution_price_field:PriceField::Open }; - let error = BacktestEngine::new(clock_probe_data(date, &[]), InvalidOpening, broker, config).run().unwrap_err(); + let date = d(2026, 6, 1); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()); + let config = BacktestConfig { + initial_cash: 10000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }; + let error = + BacktestEngine::new(clock_probe_data(date, &[]), InvalidOpening, broker, config) + .run() + .unwrap_err(); assert!(error.to_string().contains("open_auction schedule overlaps")); } diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index 94500f0..49b097d 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -1178,6 +1178,375 @@ fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() { assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1); } +#[test] +fn late_opening_rule_sees_the_etf_open_fill_after_earlier_quote_callbacks() { + use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule}; + use fidc_core::strategy::{Strategy, StrategyContext}; + use std::{cell::RefCell, rc::Rc}; + struct ObservedPool { + inner: EtfPoolSignal, + observations: Rc>>, + } + impl ObservedPool { + fn record(&self, label: &str, ctx: &StrategyContext<'_>) { + if ctx.execution_date == day(5) { + self.observations.borrow_mut().push(( + label.into(), + ctx.current_datetime().unwrap(), + ctx.portfolio + .position(&code(2)) + .map_or(0, |position| position.quantity), + ctx.fills + .iter() + .filter(|fill| fill.symbol == code(2)) + .count(), + )); + } + } + } + impl Strategy for ObservedPool { + fn name(&self) -> &str { + "late opening with ETF fill" + } + fn initial_subscriptions(&self) -> BTreeSet { + BTreeSet::from([code(1)]) + } + fn decision_quote_times(&self) -> Vec { + self.inner.decision_quote_times() + } + fn decision_quote_symbols( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result, fidc_core::BacktestError> { + self.inner.decision_quote_symbols(ctx) + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("open", ScheduleStage::OpenAuction) + .with_time_rule(ScheduleTimeRule::market_open(0, 0)), + ] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + self.record("opening", ctx); + Ok(Default::default()) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.inner.on_day(ctx) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + _: &IntradayExecutionQuote, + ) -> Result { + self.record("quote", ctx); + Ok(Default::default()) + } + } + let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap(); + let mut data = etf_fallback_fixture(time); + let quote = data.execution_quotes_on(day(5), &code(1))[0].clone(); + data.add_execution_quotes( + [(9, 15), (9, 30), (9, 32)] + .into_iter() + .map(|(hour, minute)| { + let mut row = quote.clone(); + row.timestamp = day(5).and_hms_opt(hour, minute, 0).unwrap(); + row + }) + .collect(), + ); + let observations = Rc::new(RefCell::new(Vec::new())); + let broker = 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); + let result = BacktestEngine::new( + data, + ObservedPool { + inner: EtfPoolSignal { + at: time, + condition: String::new(), + }, + observations: observations.clone(), + }, + broker, + 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(|_| Ok(vec![])) + .run() + .unwrap(); + let seen = observations.borrow(); + assert_eq!( + seen[..4], + [ + ("quote".into(), day(5).and_hms_opt(9, 15, 0).unwrap(), 0, 0), + ( + "quote".into(), + day(5).and_hms_opt(9, 30, 0).unwrap(), + 3700, + 1 + ), + ( + "opening".into(), + day(5).and_hms_opt(9, 31, 0).unwrap(), + 3700, + 1 + ), + ( + "quote".into(), + day(5).and_hms_opt(9, 32, 0).unwrap(), + 3700, + 1 + ), + ] + ); + let etf = result + .fills + .iter() + .filter(|fill| fill.symbol == code(2)) + .collect::>(); + assert_eq!(etf.len(), 1); + assert_eq!( + (etf[0].quantity, etf[0].price, etf[0].execution_timestamp), + (3700, 4., Some(day(5).and_hms_opt(9, 30, 0).unwrap())) + ); +} + +#[test] +fn no_signal_day_executes_the_etf_open_before_later_deferred_stock_orders() { + use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule}; + use fidc_core::strategy::{Strategy, StrategyContext}; + struct DeferredStockAndEtf { + inner: EtfPoolSignal, + quantity: i32, + } + impl Strategy for DeferredStockAndEtf { + fn name(&self) -> &str { + "no signal ETF and deferred stock" + } + fn requires_minute_callbacks(&self) -> bool { + false + } + fn decision_quote_times(&self) -> Vec { + self.inner.decision_quote_times() + } + fn decision_quote_symbols( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result, fidc_core::BacktestError> { + self.inner.decision_quote_symbols(ctx) + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("deferred-stock", ScheduleStage::AfterTrading) + .with_time_rule(ScheduleTimeRule::physical_time(16, 0)), + ] + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.inner.on_day(ctx) + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(if ctx.execution_date == day(2) && self.quantity != 0 { + StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: code(1), + quantity: self.quantity, + reason: "after-close stock order".into(), + }], + ..Default::default() + } + } else { + StrategyDecision::default() + }) + } + } + let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap(); + let mut parts = etf_fallback_fixture(time).snapshot_components(); + parts.factors.retain(|row| row.date != day(5)); + 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(); + for quantity in [100, -100, 0] { + let broker = 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); + let result = BacktestEngine::new( + data.clone(), + DeferredStockAndEtf { + inner: EtfPoolSignal { + at: time, + condition: String::new(), + }, + quantity, + }, + broker, + 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(|_| Ok(vec![])) + .run() + .unwrap(); + let fills = result + .fills + .iter() + .filter(|fill| fill.date == day(5)) + .collect::>(); + assert_eq!(fills.len(), if quantity < 0 { 2 } else { 1 }, "{fills:?}"); + assert_eq!( + ( + fills[0].symbol.clone(), + fills[0].quantity, + fills[0].price, + fills[0].execution_timestamp + ), + (code(2), 3700, 4., day(5).and_hms_opt(9, 30, 0)) + ); + if quantity < 0 { + assert_eq!( + ( + fills[1].symbol.clone(), + fills[1].side, + fills[1].quantity, + fills[1].execution_timestamp + ), + ( + code(1), + fidc_core::OrderSide::Sell, + 100, + Some(day(5).and_time(time)) + ) + ); + } else if quantity > 0 { + // The later stock buy cannot spend money that the 09:30 ETF fill + // already consumed. It is rejected, not allowed to shrink that fill. + assert!( + result.order_events.iter().any(|order| order.date == day(5) + && order.symbol == code(1) + && order.status == fidc_core::OrderStatus::Rejected + && order.reason.contains("cash")), + "{:?}", + result.order_events + ); + } + if quantity != 0 { + assert!( + result.equity_curve.iter().any( + |point| point.date == day(5) && point.diagnostics.contains("no_new_signal") + ) + ); + } + assert!(result.terminal_audit.is_clean()); + } + + for minute in [15, 31] { + let observed = format!("2026-01-05T01:{minute}:00Z"); + let created = format!("2026-01-05T01:{}:00Z", minute - 1); + let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05", + "executedAt":observed,"observedAt":observed,"feeObservationEventId":"receipt","feeObservationSequence":1, + "feeObservedAt":observed,"timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"}); + let order = serde_json::json!({"orderId":"manual-order","sourceAdapter":"paper","symbol":code(1),"side":"Sell","quantity":100, + "orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[fill]}); + let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({ + "schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"", + "observationCutoff":"2026-01-05T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"], + "confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[order]}]})).unwrap(); + replay.content_sha256 = replay.content_digest().unwrap(); + let broker = 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); + let result = BacktestEngine::new( + data.clone(), + DeferredStockAndEtf { + inner: EtfPoolSignal { + at: time, + condition: String::new(), + }, + quantity: 0, + }, + broker, + 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(|_| Ok(vec![])) + .with_observed_manual_executions(replay) + .unwrap() + .run(); + if minute < 30 { + assert!( + result + .unwrap_err() + .to_string() + .contains("manual observation conflicts with pending shadow orders") + ); + } else { + let result = result.unwrap(); + assert_eq!(result.manual_executions.len(), 1); + assert_eq!( + result + .fills + .iter() + .filter(|fill| fill.symbol == code(2)) + .count(), + 1 + ); + assert_eq!( + result + .daily_holdings + .iter() + .find(|position| position.date == day(5) && position.symbol == code(1)) + .unwrap() + .quantity, + 1400 + ); + } + } +} + #[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(); diff --git a/docs/manual-execution-clock-20260914.md b/docs/manual-execution-clock-20260914.md index 26f4aae..a607f6f 100644 --- a/docs/manual-execution-clock-20260914.md +++ b/docs/manual-execution-clock-20260914.md @@ -1,5 +1,7 @@ # 手工观察主时钟接入候选 +最新显式开盘/跨日 ETF 修复与验收见 [opening-and-deferred-clock-20260914.md](opening-and-deferred-clock-20260914.md)。下面保留早期阶段证据;原“开盘晚于配置窗口一律拒绝”已改为共享时钟与真实收盘边界,不再依赖订阅是否启用。 + 2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。 ## 本阶段已实现 diff --git a/docs/opening-and-deferred-clock-20260914.md b/docs/opening-and-deferred-clock-20260914.md new file mode 100644 index 0000000..9d3c31a --- /dev/null +++ b/docs/opening-and-deferred-clock-20260914.md @@ -0,0 +1,39 @@ +# 显式开盘、跨日 ETF 与委托时钟修复 + +2026-09-14。本机候选已验证,尚未发布;完整股票池工作不以本阶段关闭。 + +## 已复现的错误 + +1. `market_open(0, 0)` 的既有语义为 09:31。旧引擎先执行该开盘回调,再执行 09:30 行情回调。09:31 的手工买入 100 股提前出现在 09:30 上下文,造成未来状态可见。 +2. 开盘回调顺序修正后,`MinuteLast` 撮合仍把配置的 09:30 窗口起点当作实际执行时刻,将 09:31 新订单记成 09:30 成交;新挂单也可能记成较早起点。 +3. 没有新因子/选股快照的下一交易日,原实现先执行 13:00 的普通待执行指令,再执行 ETF 的 09:30 开盘目标。负向测试中第一笔是股票 100 股、20 元、13:00,后面才出现较早 ETF 成交。此顺序会影响实际现金分配,不能只排序最终表格。 +4. 行情可用但剩余现金不足一手时,报价撮合丢失预算阻断原因,最终错误显示“intraday quote liquidity exhausted”。 + +## 修复合同 + +- 开盘调度、已订阅行情、ETF 开盘、实际手工观察、委托窗口与到期时刻进入同一时间序列。保留 09:31 的已配置含义,不改成 09:25 避开反例。开盘阶段不允许越过收盘阶段;其可用性不再依赖是否订阅分钟回调。 +- 新订阅从实际启用时刻开始接收后续行情,不重放较早缓存报价;仍订阅中的证券不丢失其较早合法行情。 +- 新委托和续撮使用当前执行时刻,原委托创建时刻在后续重试中保留。行情来源时刻与成交时刻分开,日线/分钟/ETF 既有定价合同、价格精度、费用和证券规则不改。 +- 无新信号日不重新生成策略目标;只执行已存在意图、ETF 目标及挂单。相同时间先处理到期 ETF,再执行普通批次,后续按真实报价和到期时刻推进。没有可用信号上下文时只发布原始事实,不伪造策略回调。 +- 手工观察遇到尚未结束的影子订单/ETF 目标仍明确拒绝。不能把 09:30 的成交提前应用以让 09:15 的手工冲突消失。 +- 零成交预算阻断保留资金不足/金额预算/非法价原因,不伪装成流动性不足。真实无行情或容量不足的规则保持。 + +## 本机验证 + +源基线 Engine `232e9ae1546842224d7a21aa07d3c0696ece4b11`;Service `49f280075e6b9dce2ef149fc190cfe663411b905`、Trading `ae83fd30f56a5420a235022b0a169aaf0bae55cf`。 + +- Core 885 项通过,9 项原 ignore 不计通过。 +- Trading 工作区 625 项通过,63 项私有依赖 ignore 不计通过;没有重复运行已有数据库夹具。 +- Runner 460、API 127 项通过,16 项 ignore 不计通过。Mac 原 `is_source_row_file` dead-code 警告仍存在。 +- 手工 09:31 买入:09:30 回调 0 股,09:31 开盘回调 100 股;后续报价只处理一次。 +- NextBarOpen/MinuteLast × 订阅/未订阅四种组合:09:31 的 100 股新单恰好成交一次,时间均为 09:31;后续重试不改原创建时刻。 +- ETF 和晚开盘:09:15 回调未持有;09:30 成交 3,700 股、4 元;09:31 开盘及 09:32 行情各看见该唯一成交。 +- 无新信号日:ETF 09:30 先成交 3,700 股。13:00 股票卖出 100 股随后成交;股票买入 100 股的对照因剩余现金不足而拒绝,不能抢先花费 ETF 应使用的现金。无普通待执行意图的 ETF 单独分支也通过。 +- 同一无信号日的手工卖出:09:15 与未结束 ETF 目标冲突时拒绝;09:31、ETF 完成后的真实手工卖出应用一次,原股票持仓从 1,500 到 1,400 股。 +- 定位过程的失败、类型修正和资金不足断言修正不计通过;没有改动原池或补造行情。 + +## 发布与剩余工作 + +本修复不在已构建的 `manual-stream-20260914-FewUWP` 二进制中。该目录及既有 Linux 收据继续保留,不能覆盖或改写成包含本修复。新提交还须独立 Linux 和真实 Source/Runner 联合验收。 + +Source `d5` 版本冻结、研究/信号暂停、Live disabled 与旧任务/历史不变。Source 清单权威修复和新验证合同仍待明确解冻授权;本阶段没有发单、撤单或生产重启。后续继续公司行为、跨日保护/禁买及完整参数/适配器矩阵,不把以上确定性例子外推为全量生产完成。