diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 72de18a..081f14d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -228,6 +228,13 @@ struct RestingOrderOrigin { accepted_date: NaiveDate, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum BrokerCallbackPhase { + Normal, + ControlsOnly, + BeforeStrategy, +} + #[derive(Debug, Default)] struct BrokerExecutionSession { date: Option, @@ -463,6 +470,7 @@ pub struct BrokerSimulator { runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, runtime_execution_clock: Cell>, + runtime_callback_phase: Cell, runtime_algo_schedule: Cell>, runtime_unprocessed_algorithm_cash: Cell, runtime_decision_date: Cell>, @@ -510,6 +518,7 @@ impl BrokerSimulator { runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), runtime_execution_clock: Cell::new(None), + runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal), runtime_algo_schedule: Cell::new(None), runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO), runtime_decision_date: Cell::new(None), @@ -561,6 +570,7 @@ impl BrokerSimulator { runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), runtime_execution_clock: Cell::new(None), + runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal), runtime_algo_schedule: Cell::new(None), runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO), runtime_decision_date: Cell::new(None), @@ -1643,17 +1653,21 @@ where self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id); } } - self.process_open_orders( - date, - portfolio, - data, - &mut session.intraday_turnover, - &mut session.execution_cursors, - &mut session.global_execution_cursor, - &mut session.commission_state, - &mut report, - )?; - self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?; + if self.runtime_callback_phase.get() != BrokerCallbackPhase::ControlsOnly { + self.process_open_orders( + date, + portfolio, + data, + &mut session.intraday_turnover, + &mut session.execution_cursors, + &mut session.global_execution_cursor, + &mut session.commission_state, + &mut report, + )?; + if self.runtime_callback_phase.get() == BrokerCallbackPhase::Normal { + self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?; + } + } if !decision.order_intents.is_empty() { let mut ordered_intents = decision.order_intents.iter().collect::>(); if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash @@ -1830,6 +1844,50 @@ where ) } + #[allow(clippy::too_many_arguments)] + pub(crate) fn execute_controls_without_matching( + &self, + date: NaiveDate, + decision_date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + clock: Option, + ) -> Result { + if decision.rebalance + || !decision.target_weights.is_empty() + || !decision.exit_symbols.is_empty() + || decision.order_intents.iter().any(|intent| { + !matches!( + intent.unwrapped(), + OrderIntent::CancelOrder { .. } + | OrderIntent::CancelSymbol { .. } + | OrderIntent::CancelAll { .. } + | OrderIntent::ModifyOrder { .. } + ) + }) + { + return Err(BacktestError::Execution( + "non-matching control phase only accepts cancel or modify requests".into(), + )); + } + let _guard = RestoreCell( + &self.runtime_callback_phase, + self.runtime_callback_phase + .replace(BrokerCallbackPhase::ControlsOnly), + ); + self.execute_between_with_event_dates( + date, + decision_date, + decision_date, + portfolio, + data, + decision, + clock, + clock, + ) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn execute_coarse_at_clock( &self, @@ -1861,6 +1919,35 @@ where ) } + #[allow(clippy::too_many_arguments)] + pub(crate) fn execute_before_strategy_at_clock( + &self, + date: NaiveDate, + decision_date: NaiveDate, + order_created_date: NaiveDate, + decision_total_equity: Option, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + clock: Option, + ) -> Result { + let _guard = RestoreCell( + &self.runtime_callback_phase, + self.runtime_callback_phase + .replace(BrokerCallbackPhase::BeforeStrategy), + ); + self.execute_coarse_at_clock( + date, + decision_date, + order_created_date, + decision_total_equity, + portfolio, + data, + decision, + clock, + ) + } + pub fn execute_between_with_event_dates( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/broker/tests/algorithm_clock.rs b/crates/fidc-core/src/broker/tests/algorithm_clock.rs index b3d23f3..bc1ce66 100644 --- a/crates/fidc-core/src/broker/tests/algorithm_clock.rs +++ b/crates/fidc-core/src/broker/tests/algorithm_clock.rs @@ -675,3 +675,104 @@ fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() { assert_eq!(last.order_events.last().unwrap().filled_quantity, 200); assert!(broker.open_order_views().is_empty()); } + +#[test] +fn non_matching_controls_amend_or_cancel_without_filling_a_crossing_quote() { + let data = data(&[(0, 10., 4_000), (2, 9.4, 4_000)]); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + step( + &broker, + &mut account, + &data, + 0, + &StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: "000001.SZ".into(), + quantity: 100, + limit_price: 9.5, + reason: "resting".into(), + } + .with_time_in_force(OrderTimeInForce::Gtc), + ], + ..Default::default() + }, + ); + assert_eq!(broker.open_order_views().len(), 1); + let modify = broker + .execute_controls_without_matching( + limit_test_snapshot().date, + limit_test_snapshot().date, + &mut account, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::ModifyOrder { + order_id: 1, + new_total_quantity: Some(200), + new_limit_price: Some(9.3), + reason: "pre-open-amend".into(), + }], + ..Default::default() + }, + Some(time(2)), + ) + .unwrap(); + assert!(modify.fill_events.is_empty()); + assert_eq!(broker.open_order_views()[0].limit_price, 9.3); + assert_eq!(broker.open_order_views()[0].requested_quantity, 200); + let cancel = broker + .execute_controls_without_matching( + limit_test_snapshot().date, + limit_test_snapshot().date, + &mut account, + &data, + &StrategyDecision { + order_intents: vec![OrderIntent::CancelAll { + reason: "pre-open-cancel".into(), + }], + ..Default::default() + }, + Some(time(2)), + ) + .unwrap(); + assert!(cancel.fill_events.is_empty()); + assert_eq!( + cancel.order_events.last().unwrap().status, + OrderStatus::Canceled + ); + assert_eq!(account.cash(), 20_000.); + assert!(broker.open_order_views().is_empty()); +} + +#[test] +fn control_only_phase_cannot_be_used_to_submit_an_order_or_leave_matching_disabled() { + let data = data(&[(0, 10., 4_000)]); + let broker = broker(); + let mut account = PortfolioState::new(20_000.); + let submit = StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: "000001.SZ".into(), + quantity: 100, + reason: "normal-order".into(), + }], + ..Default::default() + }; + assert!( + broker + .execute_controls_without_matching( + limit_test_snapshot().date, + limit_test_snapshot().date, + &mut account, + &data, + &submit, + Some(time(0)) + ) + .is_err() + ); + assert_eq!(account.cash(), 20_000.); + assert_eq!( + step(&broker, &mut account, &data, 0, &submit).fill_events[0].quantity, + 100 + ); +} diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 0f4b77f..40547f4 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -991,10 +991,11 @@ where decision_date: NaiveDate, decision_index: usize, portfolio: &mut PortfolioState, - open_orders: &[crate::strategy::OpenOrderView], process_events: &mut Vec, decision: &mut crate::strategy::StrategyDecision, directive_report: &mut BrokerExecutionReport, + result: &mut BacktestResult, + callback_datetime: Option, ) -> Result<(), BacktestError> { if decision.order_intents.is_empty() { return Ok(()); @@ -1002,6 +1003,7 @@ where let mut retained = Vec::with_capacity(decision.order_intents.len()); for intent in decision.order_intents.drain(..) { + let current_orders = self.open_order_views(); match intent { crate::strategy::OrderIntent::UpdateUniverse { symbols, reason } => { let symbol_count = symbols.len(); @@ -1018,7 +1020,7 @@ where &self.data, portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1035,6 +1037,7 @@ where symbols.iter().cloned().collect::>().join(",") ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::Subscribe { symbols, reason } => { @@ -1059,7 +1062,7 @@ where &self.data, portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1075,6 +1078,7 @@ where added.join(",") ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } } @@ -1100,7 +1104,7 @@ where &self.data, portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1116,6 +1120,7 @@ where removed.join(",") ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } } @@ -1170,7 +1175,7 @@ where &self.data, &*portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1185,6 +1190,7 @@ where portfolio.cash() ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::FinanceRepay { amount, reason } => { @@ -1216,7 +1222,7 @@ where &self.data, &*portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1232,6 +1238,7 @@ where portfolio.cash_liabilities() ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::SetManagementFeeRate { rate, reason } => { @@ -1250,7 +1257,7 @@ where &self.data, &*portfolio, self.futures_account.as_ref(), - open_orders, + ¤t_orders, self.dynamic_universe.as_ref(), &self.subscriptions, process_events, @@ -1265,6 +1272,7 @@ where portfolio.management_fees() ), }, + CallbackObservation::from_result(result,callback_datetime), )?; } crate::strategy::OrderIntent::CancelOrder { order_id, reason } => { @@ -1322,6 +1330,13 @@ where } other => retained.push(other), } + Self::record_execution_history(result,directive_report,decision_date,execution_date); + if !directive_report.process_events.is_empty() { + let 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(),&orders,self.dynamic_universe.as_ref(),&self.subscriptions, + process_events,&mut directive_report.process_events,CallbackObservation::from_result(result,callback_datetime))?; + } } decision.order_intents = retained; Ok(()) @@ -2098,11 +2113,12 @@ where scheduler: &Scheduler<'_>, coarse_schedule_rules: &[ScheduleRule], portfolio: &mut PortfolioState, - result: &BacktestResult, + result: &mut BacktestResult, process_events: &mut Vec, directive_report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport, clock: Option, + earlier_target: Option, ) -> Result { let (execution_date, decision_date, decision_index, decision_total_equity) = timing; let logical_time = |stage| { @@ -2129,6 +2145,7 @@ where execution_date, ProcessEventKind::PreOnDay, "on_day:pre", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::OnDay)))), )?; let on_day_open_orders = self.open_order_views(); let decision_quote_times = self.strategy.decision_quote_times(); @@ -2199,6 +2216,7 @@ where result.order_events.as_slice(), result.fills.as_slice(), clock.filter(|_| execution_date == decision_date), + clock, )?); publish_phase_event( &mut self.strategy, @@ -2216,6 +2234,7 @@ where execution_date, ProcessEventKind::OnDay, "on_day", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::OnDay)))), )?; let bar_open_orders = self.open_order_views(); publish_phase_event( @@ -2234,6 +2253,7 @@ where execution_date, ProcessEventKind::PreBar, "bar:pre", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), )?; decision.merge_from(collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -2254,6 +2274,7 @@ where result.order_events.as_slice(), result.fills.as_slice(), clock.filter(|_| execution_date == decision_date), + clock, )?); decision.merge_from(self.strategy.on_bar(&StrategyContext { execution_date, @@ -2287,18 +2308,32 @@ where execution_date, ProcessEventKind::Bar, "bar", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, portfolio, - &on_day_open_orders, process_events, &mut decision, directive_report, + result, + stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar))), )?; + let mut superseded_audits = Vec::new(); + if let Some(mut earlier) = earlier_target { + if decision.rebalance || !decision.order_intents.is_empty() || !decision.exit_symbols.is_empty() { + decision.notes.splice(0..0, earlier.notes); + decision.diagnostics.splice(0..0, earlier.diagnostics); + decision.diagnostics.push("unsubmitted_pre_market_target_superseded".into()); + superseded_audits.append(&mut earlier.risk_decisions); + } else { + earlier.merge_from(decision); + decision = earlier; + } + } let pre_intraday_execution_orders = self.open_order_views(); self.ensure_execution_quotes_for_decision( execution_date, @@ -2319,6 +2354,8 @@ where &decision, clock, )?; + Self::record_execution_history(result, directive_report, decision_date, execution_date); + Self::record_execution_history(result, &mut intraday_report, decision_date, execution_date); let post_intraday_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, @@ -2334,6 +2371,7 @@ where &self.subscriptions, process_events, &mut intraday_report.process_events, + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), )?; report.order_events.extend(intraday_report.order_events); report.fill_events.extend(intraday_report.fill_events); @@ -2342,6 +2380,7 @@ where .extend(intraday_report.position_events); report.account_events.extend(intraday_report.account_events); report.diagnostics.extend(intraday_report.diagnostics); + decision.risk_decisions.append(&mut superseded_audits); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -2358,6 +2397,7 @@ where execution_date, ProcessEventKind::PostOnDay, "on_day:post", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::OnDay)))), )?; publish_phase_event( &mut self.strategy, @@ -2375,6 +2415,7 @@ where execution_date, ProcessEventKind::PostBar, "bar:post", + CallbackObservation::from_result(result, stage_datetime(execution_date, clock.or_else(|| default_stage_time(ScheduleStage::Bar)))), )?; Ok(decision) } @@ -2657,6 +2698,7 @@ where execution_date, ProcessEventKind::PreBeforeTrading, "before_trading:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), )?; self.strategy.before_trading(&StrategyContext { execution_date, @@ -2693,6 +2735,7 @@ where execution_date, ProcessEventKind::BeforeTrading, "before_trading", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), )?; let mut before_trading_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -2713,17 +2756,37 @@ where result.order_events.as_slice(), result.fills.as_slice(), None, + default_stage_time(ScheduleStage::BeforeTrading), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &pre_open_orders, &mut process_events, &mut before_trading_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading)), )?; + let (controls, deferred): (Vec<_>, Vec<_>) = std::mem::take(&mut before_trading_decision.order_intents) + .into_iter().partition(|intent|matches!(intent.unwrapped(), + OrderIntent::CancelOrder {..}|OrderIntent::CancelSymbol {..}|OrderIntent::CancelAll {..}|OrderIntent::ModifyOrder {..})); + before_trading_decision.order_intents = deferred; + if !controls.is_empty() { + let controls = StrategyDecision {order_intents:controls,buy_denials:before_trading_decision.buy_denials.clone(), + risk_decisions:before_trading_decision.risk_decisions.clone(),..Default::default()}; + let mut control_report = self.broker.execute_controls_without_matching(execution_date,decision_date, + &mut portfolio,&self.data,&controls,default_stage_time(ScheduleStage::BeforeTrading))?; + Self::record_execution_history(&mut result,&mut control_report,decision_date,execution_date); + let 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(),&orders,self.dynamic_universe.as_ref(),&self.subscriptions, + &mut process_events,&mut control_report.process_events, + CallbackObservation::from_result(&result,stage_datetime(execution_date,default_stage_time(ScheduleStage::BeforeTrading))))?; + merge_broker_report(&mut directive_report,control_report); + } + let pre_open_orders = self.open_order_views(); publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -2740,6 +2803,7 @@ where execution_date, ProcessEventKind::PostBeforeTrading, "before_trading:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), )?; publish_phase_event( &mut self.strategy, @@ -2757,6 +2821,7 @@ where execution_date, ProcessEventKind::PreOpenAuction, "open_auction:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; let mut auction_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -2777,6 +2842,7 @@ where result.order_events.as_slice(), result.fills.as_slice(), None, + default_stage_time(ScheduleStage::OpenAuction), )?; auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { execution_date, @@ -2813,47 +2879,63 @@ where execution_date, ProcessEventKind::OpenAuction, "open_auction", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &pre_open_orders, &mut process_events, &mut auction_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction)), )?; - let pre_auction_execution_orders = self.open_order_views(); - self.ensure_execution_quotes_for_decision( - execution_date, - decision_date, - &portfolio, - &pre_auction_execution_orders, - &auction_decision, - None, - None, - )?; + let mut pending_portfolio: Option = None; + let mut pre_day_batches = Vec::new(); + let mut pre_day_telemetry = StrategyDecision::default(); + for mut batch in [before_trading_decision, auction_decision] { + if batch.is_portfolio_target_only() { + if let Some(mut previous) = pending_portfolio.take() { + previous.merge_from(batch); + pending_portfolio = Some(previous); + } else { + pending_portfolio = Some(batch); + } + } else if batch.rebalance || !batch.order_intents.is_empty() || !batch.exit_symbols.is_empty() { + pre_day_batches.push(batch); + } else if let Some(target) = pending_portfolio.as_mut() { + target.merge_from(batch); + } else { + pre_day_telemetry.notes.append(&mut batch.notes); + pre_day_telemetry.diagnostics.append(&mut batch.diagnostics); + pre_day_telemetry.risk_decisions.append(&mut batch.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 || deferred_etf_time.is_some()).then(|| match self.broker.matching_type() { + (original_minute_clock || 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(), }) }); - let mut deferred_auction = deferred_day_time.map(|_| std::mem::take(&mut auction_decision)); - let mut report = if deferred_day_time.is_some() { BrokerExecutionReport::default() } else { self.broker.execute_with_event_dates_and_decision_equity( + let mut deferred_auction = deferred_day_time.map(|_| std::mem::take(&mut pre_day_batches)); + let mut report = if deferred_day_time.is_some() { BrokerExecutionReport::default() } else { self.broker.execute_before_strategy_at_clock( execution_date, decision_date, decision_date, decision_total_equity, &mut portfolio, &self.data, - &auction_decision, + &StrategyDecision::default(), + None, )? }; let post_auction_open_orders = self.open_order_views(); + 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); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -2868,6 +2950,7 @@ where &self.subscriptions, &mut process_events, &mut report.process_events, + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; publish_phase_event( &mut self.strategy, @@ -2885,6 +2968,7 @@ where execution_date, ProcessEventKind::PostOpenAuction, "open_auction:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), )?; Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); @@ -2892,10 +2976,14 @@ where let mut decision = if deferred_day_time.is_some() { StrategyDecision::default() } else { self.execute_day_phase( (execution_date, decision_date, decision_index, decision_total_equity), - &scheduler, &coarse_schedule_rules, &mut portfolio, &result, - &mut process_events, &mut directive_report, &mut report, None, + &scheduler, &coarse_schedule_rules, &mut portfolio, &mut result, + &mut process_events, &mut directive_report, &mut report, None, pending_portfolio.take(), )? }; + decision.notes.append(&mut pre_day_telemetry.notes); + decision.diagnostics.append(&mut pre_day_telemetry.diagnostics); + decision.risk_decisions.append(&mut pre_day_telemetry.risk_decisions); + let mut last_execution_time = self.broker.intraday_execution_start_time(); if original_minute_clock || deferred_day_time.is_some() || ((self.broker.has_open_orders() || self.broker.has_pending_stock_pool_execution()) && self.broker.drives_resting_quote_clock()) { @@ -2970,6 +3058,7 @@ where }; let minute_time = minute_timestamp.time(); last_minute_timestamp = Some(minute_timestamp); + last_execution_time = Some(minute_time); minute_group.clear(); while minute_quotes .peek() @@ -2990,30 +3079,48 @@ where || (minute_schedule_all_times && !minute_group.is_empty()); if deferred_etf_time == Some(minute_time) { deferred_etf_time = None; - let batch = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?; + let mut batch = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?; + Self::record_execution_history(&mut result, &mut batch, decision_date, execution_date); + let 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(), &orders, self.dynamic_universe.as_ref(), + &self.subscriptions, &mut process_events, &mut batch.process_events, + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; 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); if deferred_day_time == Some(minute_time) { deferred_day_time = None; - if let Some(auction) = deferred_auction.take() { - let mut batch = self.broker.execute_coarse_at_clock( + let mut before_batches = deferred_auction.take().unwrap_or_default(); + if before_batches.is_empty() {before_batches.push(StrategyDecision::default());} + for mut auction in before_batches { + let open_orders = self.open_order_views(); + self.ensure_execution_quotes_for_decision(execution_date,decision_date,&portfolio,&open_orders,&auction,Some(minute_time),Some(minute_time))?; + let mut batch = self.broker.execute_before_strategy_at_clock( execution_date, decision_date, decision_date, decision_total_equity, &mut portfolio, &self.data, &auction, Some(minute_time), )?; + Self::record_execution_history(&mut result, &mut batch, decision_date, execution_date); let 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(), &orders, self.dynamic_universe.as_ref(), - &self.subscriptions, &mut process_events, &mut batch.process_events)?; + &self.subscriptions, &mut process_events, &mut batch.process_events, + CallbackObservation::from_result(&result, Some(minute_timestamp)), + )?; merge_broker_report(&mut report, batch); Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); + decision.notes.append(&mut auction.notes); + decision.diagnostics.append(&mut auction.diagnostics); + decision.risk_decisions.append(&mut auction.risk_decisions); } decision.merge_from(self.execute_day_phase( (execution_date, decision_date, decision_index, decision_total_equity), - &scheduler, &coarse_schedule_rules, &mut portfolio, &result, - &mut process_events, &mut directive_report, &mut report, Some(minute_time), + &scheduler, &coarse_schedule_rules, &mut portfolio, &mut result, + &mut process_events, &mut directive_report, &mut report, Some(minute_time), pending_portfolio.take(), )?); 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); @@ -3043,6 +3150,7 @@ where 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 { @@ -3075,6 +3183,7 @@ where Some(minute_time), result.order_events.as_slice(), result.fills.as_slice(), + Some(minute_time), )?); } scheduled @@ -3123,16 +3232,18 @@ where 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, - &minute_open_orders, &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( @@ -3157,6 +3268,8 @@ where 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, @@ -3172,6 +3285,7 @@ where &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); @@ -3197,6 +3311,7 @@ where execution_date, ProcessEventKind::PostMinute, format!("minute:{minute_timestamp}:post"), + CallbackObservation::from_result(&result, Some(minute_timestamp)), )?; // A scheduled strategy need not subscribe to every // minute to keep a DAY/GTC limit order alive. Fetch the @@ -3238,6 +3353,13 @@ where } self.broker.finish_stock_pool_session(execution_date, &mut report); + let post_close_end = self.broker.post_close_execution_quote_window_for_order( + execution_date, execution_date, last_execution_time, + ).map(|(_, end)| end); + let after_trading_time = default_stage_time(ScheduleStage::AfterTrading) + .into_iter().chain(last_execution_time).chain(post_close_end).max(); + let settlement_time = default_stage_time(ScheduleStage::Settlement) + .into_iter().chain(after_trading_time).max(); 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); @@ -3272,6 +3394,7 @@ where execution_date, ProcessEventKind::PreAfterTrading, "after_trading:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; self.strategy.after_trading(&StrategyContext { execution_date, @@ -3287,7 +3410,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::AfterTrading), + after_trading_time, ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), @@ -3308,6 +3431,7 @@ where execution_date, ProcessEventKind::AfterTrading, "after_trading", + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; let mut after_trading_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -3327,19 +3451,23 @@ where &mut self.process_event_bus, result.order_events.as_slice(), result.fills.as_slice(), - None, + after_trading_time, + after_trading_time, )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &post_trade_open_orders, &mut process_events, &mut after_trading_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, after_trading_time), )?; let mut close_report = self.broker.after_trading(execution_date); + Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date); + Self::record_execution_history(&mut result, &mut close_report, decision_date, execution_date); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -3354,6 +3482,7 @@ where &self.subscriptions, &mut process_events, &mut close_report.process_events, + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; report.order_events.extend(close_report.order_events); report.fill_events.extend(close_report.fill_events); @@ -3384,6 +3513,7 @@ where execution_date, ProcessEventKind::PostAfterTrading, "after_trading:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; publish_phase_event( &mut self.strategy, @@ -3401,6 +3531,7 @@ where execution_date, ProcessEventKind::PreSettlement, "settlement:pre", + CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; self.strategy.on_settlement(&StrategyContext { execution_date, @@ -3416,7 +3547,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::Settlement), + settlement_time, ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), @@ -3437,6 +3568,7 @@ where execution_date, ProcessEventKind::Settlement, "settlement", + CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; let mut settlement_decision = collect_scheduled_decisions_for_stage( &mut self.strategy, @@ -3456,22 +3588,32 @@ where &mut self.process_event_bus, result.order_events.as_slice(), result.fills.as_slice(), - None, + settlement_time, + settlement_time, )?; self.apply_strategy_directives( execution_date, decision_date, decision_index, &mut portfolio, - &post_close_open_orders, &mut process_events, &mut settlement_decision, &mut directive_report, + &mut result, + stage_datetime(execution_date, settlement_time), )?; let futures_daily_settlement_report = self.settle_futures_daily(execution_date); merge_broker_report(&mut directive_report, futures_daily_settlement_report); let futures_expiration_report = self.settle_futures_expirations(execution_date); merge_broker_report(&mut directive_report, futures_expiration_report); + Self::record_execution_history(&mut result,&mut directive_report,decision_date,execution_date); + if !directive_report.process_events.is_empty() { + let 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(),&orders,self.dynamic_universe.as_ref(),&self.subscriptions, + &mut process_events,&mut directive_report.process_events, + CallbackObservation::from_result(&result,stage_datetime(execution_date,settlement_time)))?; + } let management_fee_report = if portfolio.management_fee_rate() <= 0.0 { BrokerExecutionReport::default() } else { @@ -3491,6 +3633,7 @@ where &mut process_events, result.order_events.as_slice(), result.fills.as_slice(), + settlement_time, )? }; merge_broker_report(&mut directive_report, management_fee_report); @@ -3510,6 +3653,7 @@ where execution_date, ProcessEventKind::PostSettlement, "settlement:post", + CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; merge_broker_report(&mut report, directive_report); broker_diagnostics.append(&mut report.diagnostics); @@ -4071,6 +4215,7 @@ where process_events: &mut Vec, order_events: &[OrderEvent], fills: &[FillEvent], + callback_time: Option, ) -> Result { let rate = portfolio.management_fee_rate(); if rate <= 0.0 { @@ -4094,7 +4239,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::Settlement), + callback_time, ), order_events, fills, @@ -4143,6 +4288,7 @@ where portfolio.management_fees() ), }, + CallbackObservation { datetime: stage_datetime(execution_date, callback_time), orders: order_events, fills }, )?; Ok(report) } @@ -4383,6 +4529,7 @@ fn collect_scheduled_decisions( current_time: Option, order_events: &[OrderEvent], fills: &[FillEvent], + physical_time: Option, ) -> Result { let mut combined = crate::strategy::StrategyDecision::default(); // In lagged modes such as next_bar_open, scheduled callbacks generate @@ -4418,6 +4565,7 @@ fn collect_scheduled_decisions( distinct_timed_minute_count, "pre", ), + CallbackObservation { datetime: stage_datetime(execution_date, physical_time.or(current_time)), orders: order_events, fills }, )?; combined.merge_from(strategy.on_scheduled( &StrategyContext { @@ -4460,6 +4608,7 @@ fn collect_scheduled_decisions( distinct_timed_minute_count, "post", ), + CallbackObservation { datetime: stage_datetime(execution_date, physical_time.or(current_time)), orders: order_events, fills }, )?; } Ok(combined) @@ -4505,6 +4654,7 @@ fn collect_scheduled_decisions_for_stage( order_events: &[OrderEvent], fills: &[FillEvent], default_time_override: Option, + physical_time: Option, ) -> Result { let mut times = BTreeSet::new(); for rule in rules.iter().filter(|rule| rule.stage == stage) { @@ -4547,11 +4697,25 @@ fn collect_scheduled_decisions_for_stage( time, order_events, fills, + physical_time, )?); } Ok(combined) } +#[derive(Clone, Copy)] +struct CallbackObservation<'a> { + datetime: Option, + orders: &'a [OrderEvent], + fills: &'a [FillEvent], +} + +impl<'a> CallbackObservation<'a> { + fn from_result(result: &'a BacktestResult, datetime: Option) -> Self { + Self { datetime, orders: &result.order_events, fills: &result.fills } + } +} + fn publish_phase_event( strategy: &mut S, process_event_bus: &mut ProcessEventBus, @@ -4568,6 +4732,7 @@ fn publish_phase_event( date: NaiveDate, kind: ProcessEventKind, detail: impl Into, + observation: CallbackObservation<'_>, ) -> Result<(), BacktestError> { let event = ProcessEvent { date, @@ -4591,9 +4756,9 @@ fn publish_phase_event( subscriptions, process_events, active_process_event: Some(&event), - active_datetime: None, - order_events: &[], - fills: &[], + active_datetime: observation.datetime, + order_events: observation.orders, + fills: observation.fills, }; strategy.on_process_event(&event_ctx, &event)?; events.push(event); @@ -4614,6 +4779,7 @@ fn publish_process_events( subscriptions: &BTreeSet, target: &mut Vec, incoming: &mut Vec, + observation: CallbackObservation<'_>, ) -> Result<(), BacktestError> { for event in incoming.drain(..) { process_event_bus.publish(&event); @@ -4630,9 +4796,9 @@ fn publish_process_events( subscriptions, process_events, active_process_event: Some(&event), - active_datetime: None, - order_events: &[], - fills: &[], + active_datetime: observation.datetime, + order_events: observation.orders, + fills: observation.fills, }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -4654,6 +4820,7 @@ fn publish_custom_process_event( subscriptions: &BTreeSet, target: &mut Vec, event: ProcessEvent, + observation: CallbackObservation<'_>, ) -> Result<(), BacktestError> { process_event_bus.publish(&event); let process_events = target.as_slice(); @@ -4669,9 +4836,9 @@ fn publish_custom_process_event( subscriptions, process_events, active_process_event: Some(&event), - active_datetime: None, - order_events: &[], - fills: &[], + active_datetime: observation.datetime, + order_events: observation.orders, + fills: observation.fills, }; strategy.on_process_event(&event_ctx, &event)?; target.push(event); @@ -6581,6 +6748,654 @@ mod tests { ); } + #[test] + fn process_callbacks_observe_the_completed_minute_trade_and_its_clock() { + use crate::events::{ProcessEvent, ProcessEventKind}; + struct Probe { + observed: + Rc, u32, usize, usize)>>>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "process-callback-observation" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision::default()) + } + fn on_minute( + &mut self, + _: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + Ok(StrategyDecision { + order_intents: if quote.timestamp.time() + == NaiveTime::from_hms_opt(10, 0, 0).unwrap() + { + vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "callback-observed-buy".into(), + }] + } else { + vec![] + }, + ..Default::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if matches!( + event.kind, + ProcessEventKind::Trade | ProcessEventKind::PostMinute + ) { + self.observed.borrow_mut().push(( + event.kind, + ctx.current_datetime(), + ctx.portfolio + .position(SYMBOL) + .map_or(0, |position| position.quantity), + ctx.fills.len(), + ctx.order_events.len(), + )); + } + Ok(()) + } + } + let date = d(2026, 6, 2); + let observed = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut engine = BacktestEngine::new( + clock_probe_data(date, &[(9, 30, 10.), (10, 0, 10.), (10, 1, 10.)]), + Probe { + observed: observed.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ); + let result = engine.run().unwrap(); + assert_eq!(result.fills.len(), 1); + let rows = observed.borrow(); + assert_eq!( + rows.iter() + .filter(|row| row.0 == ProcessEventKind::PostMinute) + .map(|row| (row.1, row.2, row.3)) + .collect::>(), + vec![ + (date.and_hms_opt(9, 30, 0), 0, 0), + (date.and_hms_opt(10, 0, 0), 100, 1), + (date.and_hms_opt(10, 1, 0), 100, 1) + ] + ); + let trade = rows + .iter() + .find(|row| row.0 == ProcessEventKind::Trade) + .unwrap(); + assert_eq!( + (trade.1, trade.2, trade.3), + (date.and_hms_opt(10, 0, 0), 100, 1) + ); + assert!(trade.4 > 0); + } + + #[test] + fn post_close_phase_callbacks_never_rewind_before_completed_fills() { + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "post-close-process-clock" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + order_intents: vec![ + OrderIntent::SetManagementFeeRate { + rate: 0.01, + reason: "fee-clock".into(), + }, + OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "post-close".into(), + }, + ], + ..Default::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &crate::events::ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if let Some(fill_at) = ctx + .fills + .iter() + .filter_map(|fill| fill.execution_timestamp) + .max() + { + assert!( + ctx.current_datetime().is_some_and(|clock| clock >= fill_at), + "event {:?} at {:?} already sees a later fill at {fill_at}", + event.kind, + ctx.current_datetime() + ); + } + Ok(()) + } + } + let date = d(2026, 7, 6); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + clock_probe_data( + date, + &[(15, 0, 10.), (15, 2, 10.), (15, 5, 10.), (15, 6, 10.)], + ), + Probe, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Close, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 1); + } + + #[test] + fn pre_trading_orders_wait_for_the_configured_window_and_keep_their_audit() { + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "pre-trading-order-intent" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily( + "pre-market-signal", + ScheduleStage::BeforeTrading, + )] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + assert_eq!(ctx.current_time(), NaiveTime::from_hms_opt(9, 0, 0)); + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "pre-market-signal".into(), + }], + notes: vec!["pre-market-signal-created".into()], + diagnostics: vec!["pre-market-signal-audit".into()], + ..Default::default() + }) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision::default()) + } + fn on_minute( + &mut self, + ctx: &StrategyContext<'_>, + quote: &IntradayExecutionQuote, + ) -> Result { + if quote.timestamp.time() == NaiveTime::from_hms_opt(9, 15, 0).unwrap() { + assert_eq!( + ctx.portfolio + .position(SYMBOL) + .map_or(0, |position| position.quantity), + 0 + ); + assert!(ctx.fills.is_empty()); + } + Ok(StrategyDecision::default()) + } + } + let date = d(2026, 6, 2); + for window in [ + NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + NaiveTime::from_hms_opt(13, 0, 0).unwrap(), + ] { + let broker = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(window) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + clock_probe_data(date, &[(9, 15, 10.), (9, 30, 10.), (13, 0, 10.)]), + Probe, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 1); + assert_eq!( + result.fills[0].execution_timestamp, + Some(date.and_time(window)) + ); + assert_eq!(result.fills[0].quantity, 100); + assert_eq!(result.fills[0].reason, "pre-market-signal"); + assert!( + result.equity_curve[0] + .notes + .contains("pre-market-signal-created") + ); + assert!( + result.equity_curve[0] + .diagnostics + .contains("pre-market-signal-audit") + ); + } + } + + #[test] + fn lagged_signal_callbacks_keep_signal_date_but_process_notifications_use_execution_date() { + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "lagged-callback-clocks" + } + fn initial_subscriptions(&self) -> BTreeSet { + [SYMBOL.to_string()].into() + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("signal", ScheduleStage::OnDay), + ScheduleRule::daily("after", ScheduleStage::AfterTrading), + ] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + assert_eq!(ctx.current_datetime().unwrap().date(), ctx.decision_date); + Ok(StrategyDecision::default()) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + assert_eq!(ctx.current_datetime().unwrap().date(), ctx.decision_date); + Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "lagged-signal".into(), + }], + ..Default::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &crate::events::ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if !ctx.fills.is_empty() { + assert_eq!( + ctx.current_datetime().map(|time| time.date()), + Some(ctx.execution_date), + "event {:?} mixed signal and account observation dates", + event.kind + ); + let latest = ctx + .fills + .iter() + .filter_map(|fill| fill.execution_timestamp) + .max() + .unwrap(); + assert!( + ctx.current_datetime().unwrap() >= latest, + "{:?}", + event.kind + ); + } + Ok(()) + } + } + let prior = d(2026, 6, 1); + let date = d(2026, 6, 2); + let mut data = dataset_from_market_and_candidates( + vec![market(prior, 10., 10.), market(date, 10., 10.)], + vec![candidate(prior), candidate(date)], + ); + data.add_execution_quotes( + clock_probe_data(date, &[(9, 30, 10.), (9, 31, 10.)]) + .snapshot_components() + .execution_quotes, + ); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::NextBarOpen) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + data, + Probe, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(prior), + end_date: Some(date), + decision_lag_trading_days: 1, + execution_price_field: PriceField::Open, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].decision_date, Some(prior)); + assert_eq!(result.fills[0].execution_date, Some(date)); + } + + #[test] + fn pre_market_cancel_is_observed_before_the_next_open_and_new_signal() { + struct Probe { + first: NaiveDate, + second: NaiveDate, + cancelled: Rc>, + } + impl Strategy for Probe { + fn name(&self) -> &str { + "pre-market-cancel-before-match" + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily( + "pre-market", + ScheduleStage::BeforeTrading, + )] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(if ctx.execution_date == self.second { + StrategyDecision { + order_intents: vec![ + OrderIntent::CancelOrder { + order_id: 1, + reason: "pre-market-cancel".into(), + }, + OrderIntent::Shares { + symbol: SYMBOL.into(), + quantity: 100, + reason: "new-pre-market-signal".into(), + }, + ], + ..Default::default() + } + } else { + StrategyDecision::default() + }) + } + fn open_auction( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date == self.second { + assert!(ctx.open_orders.is_empty()); + assert!(ctx.fills.is_empty()); + } + Ok(StrategyDecision::default()) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + Ok(if ctx.execution_date == self.first { + StrategyDecision { + order_intents: vec![ + OrderIntent::LimitShares { + symbol: SYMBOL.into(), + quantity: 100, + limit_price: 9.5, + reason: "original-resting-order".into(), + } + .with_time_in_force(crate::strategy::OrderTimeInForce::Gtc), + ], + ..Default::default() + } + } else { + StrategyDecision::default() + }) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &crate::events::ProcessEvent, + ) -> Result<(), crate::BacktestError> { + if event.kind == crate::events::ProcessEventKind::OrderCancellationPass { + assert_eq!(ctx.current_datetime(), self.second.and_hms_opt(9, 0, 0)); + assert!(ctx.open_orders.is_empty()); + assert!(ctx.fills.is_empty()); + assert!( + ctx.order_events + .iter() + .any(|order| order.order_id == Some(1) + && order.status == OrderStatus::Canceled) + ); + *self.cancelled.borrow_mut() = true; + } + Ok(()) + } + } + let first = d(2026, 6, 1); + let second = d(2026, 6, 2); + let cancelled = Rc::new(RefCell::new(false)); + let mut data = dataset_from_market_and_candidates( + vec![market(first, 10., 10.), market(second, 9.4, 9.4)], + vec![candidate(first), candidate(second)], + ); + for (date, price) in [(first, 10.), (second, 9.4)] { + data.add_execution_quotes( + clock_probe_data(date, &[(9, 30, price)]) + .snapshot_components() + .execution_quotes, + ); + } + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + data, + Probe { + first, + second, + cancelled: cancelled.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000852.SH".into(), + start_date: Some(first), + end_date: Some(second), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + assert!(*cancelled.borrow()); + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].order_id, Some(2)); + assert_eq!(result.fills[0].quantity, 100); + assert_eq!( + result.fills[0].execution_timestamp, + second.and_hms_opt(9, 30, 0) + ); + } + + #[test] + fn latest_unsent_complete_portfolio_does_not_union_an_older_pre_market_target() { + const LATEST: &str = "000002.SZ"; + struct Probe(u8); + impl Strategy for Probe { + fn name(&self) -> &str { + "latest-complete-target" + } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily( + "pre-market", + ScheduleStage::BeforeTrading, + )] + } + fn on_scheduled( + &mut self, + _: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + Ok(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([(SYMBOL.into(), 1.)]), + ..Default::default() + }) + } + fn open_auction( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([(LATEST.into(), 1.)]), + buy_denials: BTreeMap::from([(SYMBOL.into(), "old_auction_condition".into())]), + ..Default::default() + }) + } + fn on_day( + &mut self, + _: &StrategyContext<'_>, + ) -> Result { + Ok(match self.0 { + 0 => StrategyDecision::default(), + 1 => StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([(SYMBOL.into(), 1.)]), + ..Default::default() + }, + _ => StrategyDecision { + rebalance: true, + ..Default::default() + }, + }) + } + } + let date = d(2026, 6, 2); + let mut rows = clock_probe_data(date, &[(9, 30, 10.)]).snapshot_components(); + let mut instrument = rows.instruments[0].clone(); + instrument.symbol = LATEST.into(); + rows.instruments.push(instrument); + let mut snapshot = rows.market[0].clone(); + snapshot.symbol = LATEST.into(); + rows.market.push(snapshot); + let mut candidate = rows.candidates[0].clone(); + candidate.symbol = LATEST.into(); + rows.candidates.push(candidate); + let mut quote = rows.execution_quotes[0].clone(); + quote.symbol = LATEST.into(); + rows.execution_quotes.push(quote); + let data = DataSet::from_components_with_actions_and_quotes( + rows.instruments, + rows.market, + rows.factors, + rows.candidates, + rows.benchmarks, + rows.corporate_actions, + rows.execution_quotes, + ) + .unwrap(); + for mode in [0, 1, 2] { + let broker = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let result = BacktestEngine::new( + data.clone(), + Probe(mode), + broker, + BacktestConfig { + initial_cash: 10_000., + benchmark_code: "000852.SH".into(), + start_date: Some(date), + end_date: Some(date), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Last, + }, + ) + .run() + .unwrap(); + if mode == 2 { + assert!(result.fills.is_empty()); + assert!(result.holdings_summary.is_empty()); + } else { + let expected = if mode == 0 { LATEST } else { SYMBOL }; + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].symbol, expected); + assert_eq!(result.holdings_summary.len(), 1); + assert_eq!(result.holdings_summary[0].symbol, expected); + } + } + } + #[test] fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() { let date = d(2026, 7, 6); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 1c1ab2c..2a91976 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -990,6 +990,15 @@ pub struct StrategyDecision { } impl StrategyDecision { + pub(crate) fn is_portfolio_target_only(&self) -> bool { + (self.rebalance && self.order_intents.is_empty()) + || (self.order_intents.len() == 1 + && matches!( + self.order_intents[0].unwrapped(), + OrderIntent::StockPool { .. } | OrderIntent::TargetPortfolioSmart { .. } + )) + } + pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet { let mut symbols = BTreeSet::new(); if self.rebalance { @@ -1003,9 +1012,24 @@ impl StrategyDecision { } pub fn merge_from(&mut self, mut other: StrategyDecision) { + if self.is_portfolio_target_only() && other.is_portfolio_target_only() { + let mut previous = std::mem::replace(self, other); + previous + .diagnostics + .push("unsubmitted_portfolio_target_superseded".into()); + self.notes.splice(0..0, previous.notes); + self.diagnostics.splice(0..0, previous.diagnostics); + return; + } self.buy_denials.append(&mut other.buy_denials); - self.rebalance |= other.rebalance; - self.target_weights.append(&mut other.target_weights); + if other.rebalance { + // Rebalance targets are a complete portfolio, not an additive + // list. A newer unsent target replaces the earlier allocation. + self.rebalance = true; + self.target_weights = std::mem::take(&mut other.target_weights); + } else { + self.target_weights.append(&mut other.target_weights); + } self.exit_symbols.append(&mut other.exit_symbols); self.order_intents.append(&mut other.order_intents); self.notes.append(&mut other.notes); @@ -1025,6 +1049,52 @@ impl StrategyDecision { } } +#[cfg(test)] +mod decision_merge_tests { + use super::*; + + #[test] + fn newer_complete_target_replaces_old_symbols_without_discarding_explicit_actions() { + let mut earlier = StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("A".into(), 0.5), ("B".into(), 0.5)]), + exit_symbols: BTreeSet::from(["risk_exit".into()]), + order_intents: vec![OrderIntent::Shares { + symbol: "explicit".into(), + quantity: 100, + reason: "explicit action".into(), + }], + ..Default::default() + }; + earlier.merge_from(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("C".into(), 1.)]), + ..Default::default() + }); + assert_eq!(earlier.target_weights, BTreeMap::from([("C".into(), 1.)])); + assert!(earlier.rebalance); + assert!(earlier.exit_symbols.contains("risk_exit")); + assert_eq!(earlier.order_intents.len(), 1); + } + + #[test] + fn explicit_empty_complete_target_replaces_old_allocation_but_empty_callback_does_not() { + let mut decision = StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("A".into(), 1.)]), + ..Default::default() + }; + decision.merge_from(StrategyDecision::default()); + assert_eq!(decision.target_weights.len(), 1); + decision.merge_from(StrategyDecision { + rebalance: true, + ..Default::default() + }); + assert!(decision.target_weights.is_empty()); + assert!(decision.rebalance); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AlgoOrderStyle { Vwap, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 81362b6..e4268d1 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() { assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); } +#[test] +fn futures_directive_notifications_include_the_actual_recorded_fill() { + struct Observed { + inner: FuturesOrderStrategy, + seen: Rc>>, + } + impl Strategy for Observed { + fn name(&self) -> &str { + "observed-futures-directive" + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + self.inner.on_day(ctx) + } + fn on_process_event( + &mut self, + ctx: &StrategyContext<'_>, + event: &ProcessEvent, + ) -> Result<(), fidc_core::BacktestError> { + if event.kind == ProcessEventKind::Trade + && event.symbol.as_deref() == Some("IF2501") + { + let id = event.order_id.unwrap(); + assert!( + ctx.fills + .iter() + .any(|fill| fill.order_id == Some(id) && fill.symbol == "IF2501") + ); + assert!( + ctx.order_events + .iter() + .any(|order| order.order_id == Some(id) + && order.status == OrderStatus::Filled) + ); + assert_eq!( + ctx.current_datetime().map(|time| time.date()), + Some(ctx.execution_date) + ); + self.seen.borrow_mut().push(id); + } + Ok(()) + } + } + let seen = Rc::new(RefCell::new(Vec::new())); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_capacity_mode( + fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit, + ); + let mut engine = BacktestEngine::new( + two_day_futures_data(), + Observed { + inner: FuturesOrderStrategy, + seen: seen.clone(), + }, + broker, + BacktestConfig { + initial_cash: 100_000., + benchmark_code: "000300.SH".into(), + start_date: Some(d(2025, 1, 2)), + end_date: Some(d(2025, 1, 3)), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ) + .with_futures_initial_cash(500_000.); + let result = engine.run().unwrap(); + assert_eq!( + *seen.borrow(), + result + .fills + .iter() + .filter(|fill| fill.symbol == "IF2501") + .map(|fill| fill.order_id.unwrap()) + .collect::>() + ); + assert_eq!(seen.borrow().len(), 1); +} + #[test] fn platform_runtime_actions_execute_generic_futures_open_and_close() { let mut cfg = PlatformExprStrategyConfig::generic(); diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index c9cb6c1..da33319 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -224,6 +224,117 @@ fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision { } } +#[test] +fn a_fresh_zero_target_prevents_resuming_the_previous_unsubmitted_buy_leg() { + use fidc_core::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext}; + struct Probe; + impl Strategy for Probe { + fn name(&self) -> &str { + "fresh-target-before-resume" + } + fn requires_minute_callbacks(&self) -> bool { + false + } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("earlier-pool", ScheduleStage::Minute) + .with_time_rule(ScheduleTimeRule::physical_time(9, 30)), + ] + } + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _: &ScheduleRule, + ) -> Result { + if ctx.execution_date != day(5) { + return Ok(StrategyDecision::default()); + } + let mut old = contract(day(5), 2, false); + old.out_of_pool_policy = "reduce_to_zero_when_sellable".into(); + old.rule.window_end = "13:30".into(); + old.rule.pricing_mode = POOL_PRICE_FORMULA_LIMIT.into(); + old.generation = "earlier-pool-at-open".into(); + Ok(decision(old)) + } + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { + if ctx.execution_date == day(2) { + return Ok(StrategyDecision { + order_intents: vec![OrderIntent::Shares { + symbol: code(1), + quantity: 100, + reason: "original-holding".into(), + }], + ..Default::default() + }); + } + assert!(ctx.open_orders.is_empty()); + let mut latest = contract(day(5), 2, false); + latest.out_of_pool_policy = "reduce_to_zero_when_sellable".into(); + latest.rule.window_end = "13:30".into(); + latest.invest_ratio_bps = 0; + latest.generation = "fresh-zero-at-1300".into(); + Ok(decision(latest)) + } + } + let mut rows = data(false).snapshot_components(); + let mut quotes = Vec::new(); + for mut quote in rows.execution_quotes { + if quote.date > day(5) { + continue; + } + let mut afternoon = quote.clone(); + afternoon.timestamp = quote.date.and_hms_opt(13, 0, 0).unwrap(); + quotes.push(afternoon); + if quote.date == day(5) && quote.symbol == code(1) { + quote.volume_delta = 100; + quote.amount_delta = quote.last_price * 100.; + } + quotes.push(quote); + } + rows.execution_quotes = quotes; + let data = DataSet::from_components_with_actions_and_quotes( + rows.instruments, + rows.market, + rows.factors, + rows.candidates, + rows.benchmarks, + rows.corporate_actions, + rows.execution_quotes, + ) + .unwrap(); + let broker = broker(true) + .with_matching_type(MatchingType::MinuteLast) + .with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap()); + let result = BacktestEngine::new( + data, + Probe, + broker, + BacktestConfig { + initial_cash: 30_000., + 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, + }, + ) + .run() + .unwrap(); + assert_eq!(result.fills.len(), 3, "{:?}", result.fills); + assert!(result.fills.iter().all(|fill| fill.symbol == code(1))); + assert_eq!(result.fills[1].side, fidc_core::OrderSide::Sell); + assert_eq!( + result.fills[2].execution_timestamp, + day(5).and_hms_opt(13, 0, 0) + ); + assert_eq!(result.fills[1].order_id, result.fills[2].order_id); + assert_eq!(result.fills[1].quantity + result.fills[2].quantity, 100); + assert!(result.holdings_summary.is_empty()); +} + #[test] fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() { let data = data_with_suspension(1_000_000, Some(day(6))); diff --git a/docs/callback-context-and-pending-targets-20260914.md b/docs/callback-context-and-pending-targets-20260914.md new file mode 100644 index 0000000..1b85c5e --- /dev/null +++ b/docs/callback-context-and-pending-targets-20260914.md @@ -0,0 +1,35 @@ +# 回报上下文、盘前意图与尚未提交的目标 + +2026-09-14。本轮为v2026.9.14.4之后的候选,当前只有本机验证,尚未发布;完整股票池Goal继续。 + +## 已复现问题 + +1. `on_process_event`总是收到`active_datetime=None`及空委托/成交数组。10:00账本已有100股,但Trade/PostMinute回调的成交数量仍为0;不能靠普通`on_minute`已修复就认为通知链也完整。 +2. 15:05盘后成交后,PreAfterTrading仍被标为15:00;跨日模式的PostOnDay又使用信号日描述执行日已发生的成交。 +3. BeforeTrading调度只处理订阅、账户和期货指令,剩余股票买卖/撤改意图没有后续消费。简单在开盘调用普通broker执行还会让旧挂单先成交再撤单。 +4. 合并完整目标时只追加权重会保留旧证券;更重要的是,不能先提交盘前旧组合,之后才计算同一窗口的新目标,否则T+1可能使错误买入无法纠正。 +5. 策略计算前的空broker调用也会恢复上一目标的未提交买入腿。反例中原持仓100股,09:30卖25股、13:00卖剩余75股;若此时先恢复旧买入,已经准备将新目标设为0%的策略仍会买入另一股票3000股。 + +## 本轮处理 + +- 事件通知显式携带当前可见的委托、成交与回调时钟,移动已完成记录后再通知,不按每个回调复制整段历史。上下文是通知时已完成批次的最新状态,不冒充每一历史通知发生瞬间的账本快照。 +- 信号计算回调保留信号日;账户/委托通知使用实际执行日与物理时钟。默认收盘和结算不早于已处理时刻及当前适用的盘后结束点,管理费回调沿用同一完成时钟。 +- 盘前撤改走明确的非撮合控制阶段,保持原订单ID和实际已成交量;该入口拒绝买卖目标,不会顺带撮合旧单。普通显式买卖按原配置窗口执行,后续回调读取撤改后的真实活动订单。 +- 盘前与集合竞价的显式命令保留各自批次及约束。纯完整组合(完整rebalance或单一StockPool/TargetPortfolioSmart)可以被更新的完整意图替换;空回调不等于清仓,显式空完整目标才清仓。被替换意图的旧买入限制不能污染新完整目标。 +- 尚未提交的完整目标保留到当前窗口日度策略算完;新执行意图优先,只有没有新执行意图时才使用前面的目标。已提交挂单可以先更新实际成交,但策略计算前不恢复旧的未提交买入腿,之后再由正常执行路径处理当前意图。 +- 订阅/账户/直接期货指令通知同样获得完成后的历史;本轮不改变期货成交、会话或费用规则。 + +## 回归证据 + +- 通知链:09:30为空、10:00/10:01均看到100股及1笔实际成交,Trade通知可找到相同订单。 +- 盘后:15:05成交后的默认收盘/结算和管理费通知不倒退;next-open保持独立信号日和执行日。 +- 盘前:09:00生成100股命令,分别只在09:30/13:00配置窗口成交;保留备注/诊断。跨日撤销原GTC订单后,新订单只成交100股,未让旧单先成交。 +- 完整目标:盘前A、集合竞价B、日度A或显式空目标,最终只采用有效最新目标;日度无新信号时保持B。显式逐股命令不会被目标合并丢弃。 +- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。 +- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。 + +当前代码尚需精确Linux构建、真实历史合同回放和配套发布;不得把本机验证当生产或真实券商成交验收。 + +## 继续范围 + +显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。