From fb8192a286b7113b87f60291f839d3a8f401df61 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 12:27:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=9B=98=E5=90=8E=E6=84=8F?= =?UTF-8?q?=E5=9B=BE=E4=BF=9D=E7=95=99=E5=8F=8A=E6=89=8B=E5=B7=A5=E8=A7=82?= =?UTF-8?q?=E5=AF=9F=E7=9A=84=E9=98=B6=E6=AE=B5=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 570 ++++++++++++++----- crates/fidc-core/src/finite_serialization.rs | 196 +++++++ crates/fidc-core/src/futures.rs | 8 +- crates/fidc-core/src/holding_policy.rs | 2 +- crates/fidc-core/src/lib.rs | 3 +- crates/fidc-core/src/stock_pool_execution.rs | 4 +- crates/fidc-core/src/strategy.rs | 10 +- docs/manual-execution-clock-20260914.md | 15 +- 8 files changed, 657 insertions(+), 151 deletions(-) create mode 100644 crates/fidc-core/src/finite_serialization.rs diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index f1cfb9f..af03644 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -170,6 +170,10 @@ pub enum BacktestTerminalAssetClass { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BacktestTerminalAudit { + #[serde(default, skip_serializing_if = "is_zero_count")] + pub deferred_strategy_decision_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deferred_strategy_decisions: Vec, #[serde(default, skip_serializing_if = "is_zero_count")] pub deferred_etf_target_count: usize, pub status: BacktestTerminalStatus, @@ -188,6 +192,8 @@ pub struct BacktestTerminalAudit { impl Default for BacktestTerminalAudit { fn default() -> Self { Self { + deferred_strategy_decision_count: 0, + deferred_strategy_decisions: Vec::new(), deferred_etf_target_count: 0, status: BacktestTerminalStatus::Clean, last_execution_date: None, @@ -482,6 +488,16 @@ pub struct BacktestEngine { execution_lifecycle_reported: BTreeSet<(String, String)>, risk_free_rate_contract: Option, manual_execution_source: Option>, + deferred_session_decisions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct DeferredSessionDecision { + created_at: chrono::NaiveDateTime, + decision_date: NaiveDate, + decision_index: usize, + decision_equity: f64, + decision: StrategyDecision, } fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool { @@ -550,6 +566,31 @@ pub fn backtest_execution_dates( .collect() } +fn backtest_execution_schedule_with_rules( + data: &DataSet, start_date: Option, end_date: Option, + decision_lag_trading_days: usize, rules: &[ScheduleRule], +) -> Vec<(NaiveDate, Option<(usize, NaiveDate)>)> { + let schedule = backtest_execution_schedule(data, start_date, end_date, decision_lag_trading_days); + if !rules.iter().any(|rule| matches!(rule.stage, ScheduleStage::AfterTrading | ScheduleStage::Settlement)) { + return schedule; + } + let planned = schedule.into_iter().collect::>(); + data.calendar().iter() + .filter(|date| start_date.is_none_or(|start| *date >= start)) + .filter(|date| end_date.is_none_or(|end| *date <= end)) + .map(|date| (date, planned.get(&date).copied().flatten())).collect() +} + +/// Use the declared lifecycle rules when aligning outside data (for example, +/// risk-free observations) with the engine's complete equity calendar. +pub fn backtest_execution_dates_with_rules( + data: &DataSet, start_date: Option, end_date: Option, + decision_lag_trading_days: usize, rules: &[ScheduleRule], +) -> Vec { + backtest_execution_schedule_with_rules(data, start_date, end_date, decision_lag_trading_days, rules) + .into_iter().map(|(date, _)| date).collect() +} + impl BacktestEngine { pub fn new( data: DataSet, @@ -582,6 +623,7 @@ impl BacktestEngine { execution_lifecycle_reported: BTreeSet::new(), risk_free_rate_contract: None, manual_execution_source: None, + deferred_session_decisions: Vec::new(), } } @@ -776,24 +818,25 @@ where Ok(()) } - fn collect_before_trading_schedules( + fn collect_lifecycle_schedules( &mut self, scheduler: &Scheduler<'_>, execution_date: NaiveDate, decision_date: NaiveDate, - decision_index: usize, rules: &[ScheduleRule], portfolio: &mut PortfolioState, + decision_index: usize, stage: ScheduleStage, rules: &[ScheduleRule], + phase_start: NaiveTime, phase_limit: Option, default_override: Option, portfolio: &mut PortfolioState, cursor: &mut Option, result: &mut BacktestResult, events: &mut Vec, directive_report: &mut BrokerExecutionReport, ) -> Result<(StrategyDecision, NaiveTime), BacktestError> { let active_rules = rules.iter().filter(|rule| scheduler.is_due_on(decision_date, rule)).cloned().collect::>(); - let times = scheduled_stage_times(ScheduleStage::BeforeTrading, &active_rules, None)?; - let phase_end = times.iter().flatten().copied().max().or_else(|| default_stage_time(ScheduleStage::BeforeTrading)).unwrap(); - if phase_end > default_stage_time(ScheduleStage::OpenAuction).unwrap() { - return Err(BacktestError::Execution("before_trading schedule overlaps the opening phase; use a trading-session stage for later callbacks".into())); + let times = scheduled_stage_times(stage, &active_rules, default_override)?; + let phase_end = times.iter().flatten().copied().chain(std::iter::once(phase_start)).max().unwrap(); + if times.iter().flatten().any(|time| *time < phase_start) || phase_limit.is_some_and(|limit| phase_end > limit) { + return Err(BacktestError::Execution(format!("{} schedule overlaps another lifecycle phase; use a trading-session stage for in-session callbacks", stage_label(stage)))); } let mut combined = StrategyDecision::default(); for time in times.into_iter().flatten() { self.observe_manual_until(cursor, execution_date.and_time(time), portfolio, result, events, Some((decision_date, decision_index)))?; let orders = self.open_order_views(); let mut decision = collect_scheduled_decisions(&mut self.strategy, scheduler, execution_date, - ScheduleStage::BeforeTrading, &active_rules, decision_date, decision_index, &self.data, portfolio, + stage, &active_rules, decision_date, decision_index, &self.data, portfolio, self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions, events, &mut self.process_event_bus, Some(time), &result.order_events, &result.fills, Some(time))?; self.apply_strategy_directives(execution_date, decision_date, decision_index, portfolio, events, @@ -811,11 +854,104 @@ where events, &mut report.process_events, CallbackObservation::from_result(result, Some(execution_date.and_time(time))))?; merge_broker_report(directive_report, report); } + if matches!(stage, ScheduleStage::AfterTrading | ScheduleStage::Settlement) + && (decision.rebalance || !decision.order_intents.is_empty() || !decision.exit_symbols.is_empty()) { + let mut pending = decision.clone(); + pending.notes.clear(); pending.diagnostics.clear(); + let deferred = DeferredSessionDecision { + created_at: execution_date.and_time(time), decision_date, decision_index, + decision_equity: portfolio.total_equity(), decision: pending, + }; + crate::finite_serialization::validate(&deferred) + .map_err(|error| BacktestError::Execution(error.to_string()))?; + if deferred.decision.is_portfolio_target_only() { + let previous_count = self.deferred_session_decisions.len(); + self.deferred_session_decisions.retain(|previous| !previous.decision.is_portfolio_target_only()); + if self.deferred_session_decisions.len() < previous_count { + decision.diagnostics.push("unsubmitted_portfolio_target_superseded".into()); + } + } + self.deferred_session_decisions.push(deferred); + decision.rebalance = false; decision.target_weights.clear(); decision.order_intents.clear(); decision.exit_symbols.clear(); + decision.diagnostics.push(format!("strategy_intent_deferred_to_next_session origin_stage={} created_at={}", stage_label(stage), execution_date.and_time(time))); + } combined.merge_from(decision); } Ok((combined, phase_end)) } + /// 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( + &mut self, date: NaiveDate, portfolio: &mut PortfolioState, + cursor: &mut Option, + result: &mut BacktestResult, events: &mut Vec, + ) -> 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(); + 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); + } + 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); + 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; }; + 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); + } + report.diagnostics.push("deferred_strategy_session_no_new_signal original_signal_preserved=true".into()); + Ok(report) + } + fn ensure_execution_quotes_for_decision( &mut self, execution_date: NaiveDate, @@ -1468,7 +1604,7 @@ where &self, portfolio: &PortfolioState, last_execution_date: Option, - ) -> BacktestTerminalAudit { + ) -> Result { const OPEN_ORDER_SAMPLE_LIMIT: usize = 20; let stock_open_orders = self.broker.open_order_views(); @@ -1524,13 +1660,17 @@ where && pending_cash_flow_count == 0 && cash_receivable_count == 0 && self.broker.pending_etf_target_count() == 0 + && self.deferred_session_decisions.is_empty() { BacktestTerminalStatus::Clean } else { BacktestTerminalStatus::CompletedWithPendingState }; - BacktestTerminalAudit { + Ok(BacktestTerminalAudit { + deferred_strategy_decision_count: self.deferred_session_decisions.len(), + deferred_strategy_decisions: self.deferred_session_decisions.iter().map(serde_json::to_value) + .collect::, _>>().map_err(|error| BacktestError::Execution(format!("cannot retain deferred strategy intent: {error}")))?, deferred_etf_target_count: self.broker.pending_etf_target_count(), status, last_execution_date, @@ -1543,7 +1683,7 @@ where earliest_deferred_cash_date, omitted_open_order_count: open_order_count.saturating_sub(open_order_samples.len()), open_order_samples, - } + }) } fn has_open_orders(&self) -> bool { @@ -2212,7 +2352,7 @@ where directive_report: &mut BrokerExecutionReport, report: &mut BrokerExecutionReport, clock: Option, - earlier_target: Option, + earlier_target: Option, ) -> Result { let (execution_date, decision_date, decision_index, decision_total_equity) = timing; let logical_time = |stage| { @@ -2417,21 +2557,23 @@ where )?; let mut superseded_audits = Vec::new(); + let mut execution_origin = (decision_date, decision_date, decision_total_equity); 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.notes.splice(0..0, earlier.decision.notes); + decision.diagnostics.splice(0..0, earlier.decision.diagnostics); decision.diagnostics.push("unsubmitted_pre_market_target_superseded".into()); - superseded_audits.append(&mut earlier.risk_decisions); + superseded_audits.append(&mut earlier.decision.risk_decisions); } else { - earlier.merge_from(decision); - decision = earlier; + earlier.decision.merge_from(decision); + execution_origin = (earlier.decision_date, earlier.created_at.date(), Some(earlier.decision_equity)); + decision = earlier.decision; } } let pre_intraday_execution_orders = self.open_order_views(); self.ensure_execution_quotes_for_decision( execution_date, - decision_date, + execution_origin.1, portfolio, &pre_intraday_execution_orders, &decision, @@ -2440,14 +2582,15 @@ where )?; let mut intraday_report = self.broker.execute_coarse_at_clock( execution_date, - decision_date, - decision_date, - decision_total_equity, + execution_origin.0, + execution_origin.1, + execution_origin.2, portfolio, &self.data, &decision, clock, )?; + annotate_broker_report_dates(&mut intraday_report, execution_origin.0, execution_origin.1, execution_date); 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(); @@ -2541,11 +2684,12 @@ where self.subscriptions = self.strategy.initial_subscriptions(); let scheduler_calendar = self.data.calendar().clone(); let scheduler = Scheduler::new(&scheduler_calendar); - let execution_schedule = backtest_execution_schedule( + let execution_schedule = backtest_execution_schedule_with_rules( &self.data, self.config.start_date, self.config.end_date, self.config.decision_lag_trading_days, + &self.strategy.schedule_rules(), ); let execution_dates = execution_schedule .iter() @@ -2665,7 +2809,10 @@ 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.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?; + 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)?; portfolio.update_prices_with_options( @@ -2780,6 +2927,9 @@ where let active_before_rules = schedule_rules.iter().filter(|rule| rule.stage == ScheduleStage::BeforeTrading && scheduler.is_due_on(decision_date, rule)).cloned().collect::>(); let before_start_time = scheduled_stage_times(ScheduleStage::BeforeTrading, &active_before_rules, None)?.into_iter().flatten() .chain(default_stage_time(ScheduleStage::BeforeTrading)).min().unwrap(); + let active_opening_rules = schedule_rules.iter().filter(|rule| rule.stage == ScheduleStage::OpenAuction && scheduler.is_due_on(decision_date, rule)).cloned().collect::>(); + let opening_start_time = scheduled_stage_times(ScheduleStage::OpenAuction, &active_opening_rules, None)?.into_iter().flatten() + .chain(default_stage_time(ScheduleStage::OpenAuction)).min().unwrap(); self.observe_manual_until(&mut manual_cursor, execution_date.and_time(before_start_time), &mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?; @@ -2857,8 +3007,9 @@ where "before_trading", CallbackObservation::from_result(&result, Some(execution_date.and_time(before_start_time))), )?; - let (before_trading_decision, before_end_time) = self.collect_before_trading_schedules( - &scheduler, execution_date, decision_date, decision_index, &coarse_schedule_rules, + let (before_trading_decision, before_end_time) = self.collect_lifecycle_schedules( + &scheduler, execution_date, decision_date, decision_index, ScheduleStage::BeforeTrading, &coarse_schedule_rules, + before_start_time, Some(opening_start_time), None, &mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report, )?; let pre_open_orders = self.open_order_views(); @@ -2881,7 +3032,7 @@ where CallbackObservation::from_result(&result, Some(execution_date.and_time(before_end_time))), )?; self.observe_manual_until(&mut manual_cursor, - execution_date.and_time(default_stage_time(ScheduleStage::OpenAuction).expect("auction clock")), + 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, @@ -2899,28 +3050,12 @@ where execution_date, ProcessEventKind::PreOpenAuction, "open_auction:pre", - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), + CallbackObservation::from_result(&result, Some(execution_date.and_time(opening_start_time))), )?; - let mut auction_decision = collect_scheduled_decisions_for_stage( - &mut self.strategy, - &scheduler, - execution_date, - ScheduleStage::OpenAuction, - &coarse_schedule_rules, - 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, - &mut self.process_event_bus, - result.order_events.as_slice(), - result.fills.as_slice(), - None, - default_stage_time(ScheduleStage::OpenAuction), + 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, @@ -2936,7 +3071,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::OpenAuction), + Some(opening_end_time), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), @@ -2957,7 +3092,7 @@ where execution_date, ProcessEventKind::OpenAuction, "open_auction", - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), + CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))), )?; self.apply_strategy_directives( execution_date, @@ -2968,27 +3103,33 @@ where &mut auction_decision, &mut directive_report, &mut result, - stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction)), + stage_datetime(execution_date, Some(opening_end_time)), )?; - let mut pending_portfolio: Option = 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() { + 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 }, + ]); + for mut batch in all_pre_day { + if batch.decision.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); + 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()); + pre_day_telemetry.risk_decisions.append(&mut previous.decision.risk_decisions); } - } else if batch.rebalance || !batch.order_intents.is_empty() || !batch.exit_symbols.is_empty() { + pending_portfolio = Some(batch); + } else if batch.decision.rebalance || !batch.decision.order_intents.is_empty() || !batch.decision.exit_symbols.is_empty() { pre_day_batches.push(batch); } else if let Some(target) = pending_portfolio.as_mut() { - target.merge_from(batch); + target.decision.merge_from(batch.decision); } 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); + pre_day_telemetry.notes.append(&mut batch.decision.notes); + pre_day_telemetry.diagnostics.append(&mut batch.decision.diagnostics); + pre_day_telemetry.risk_decisions.append(&mut batch.decision.risk_decisions); } } let original_minute_clock = should_run_minute_events(&intraday_schedule_rules, &self.subscriptions); @@ -3000,6 +3141,7 @@ where _ => NaiveTime::from_hms_opt(9, 30, 0).unwrap(), }) }); + deferred_day_time = deferred_day_time.map(|time| time.max(opening_end_time)); 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, @@ -3028,7 +3170,7 @@ where &self.subscriptions, &mut process_events, &mut report.process_events, - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), + CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))), )?; publish_phase_event( &mut self.strategy, @@ -3046,7 +3188,7 @@ where execution_date, ProcessEventKind::PostOpenAuction, "open_auction:post", - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))), + CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))), )?; Self::record_execution_history(&mut result, &mut report, decision_date, execution_date); @@ -3181,14 +3323,18 @@ where if deferred_day_time == Some(minute_time) { deferred_day_time = None; let mut before_batches = deferred_auction.take().unwrap_or_default(); - if before_batches.is_empty() {before_batches.push(StrategyDecision::default());} + if before_batches.is_empty() {before_batches.push(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: 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))?; + self.ensure_execution_quotes_for_decision(execution_date,auction.created_at.date(),&portfolio,&open_orders,&auction.decision,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), + execution_date, auction.decision_date, auction.created_at.date(), Some(auction.decision_equity), + &mut portfolio, &self.data, &auction.decision, Some(minute_time), )?; + annotate_broker_report_dates(&mut batch, auction.decision_date, auction.created_at.date(), execution_date); 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, @@ -3199,9 +3345,9 @@ where )?; 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.notes.append(&mut auction.decision.notes); + decision.diagnostics.append(&mut auction.decision.diagnostics); + decision.risk_decisions.append(&mut auction.decision.risk_decisions); } decision.merge_from(self.execute_day_phase( (execution_date, decision_date, decision_index, decision_total_equity), @@ -3444,7 +3590,7 @@ where ).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) + let mut settlement_time = default_stage_time(ScheduleStage::Settlement) .into_iter().chain(after_trading_time).max(); self.observe_manual_until(&mut manual_cursor, execution_date.and_time(after_trading_time.expect("after-trading clock")), &mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?; @@ -3521,41 +3667,10 @@ where "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, - &scheduler, - execution_date, - ScheduleStage::AfterTrading, - &coarse_schedule_rules, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_trade_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut self.process_event_bus, - result.order_events.as_slice(), - result.fills.as_slice(), - after_trading_time, - after_trading_time, - )?; - self.apply_strategy_directives( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &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); + let post_trade_open_orders = self.open_order_views(); publish_process_events( &mut self.strategy, &mut self.process_event_bus, @@ -3572,6 +3687,13 @@ where &mut close_report.process_events, CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), )?; + let (after_trading_decision, after_end_time) = self.collect_lifecycle_schedules( + &scheduler, execution_date, decision_date, decision_index, ScheduleStage::AfterTrading, &coarse_schedule_rules, + after_trading_time.expect("after-trading clock"), None, after_trading_time, + &mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report, + )?; + decision.merge_from(after_trading_decision); + settlement_time = Some(settlement_time.expect("settlement clock").max(after_end_time)); report.order_events.extend(close_report.order_events); report.fill_events.extend(close_report.fill_events); report.position_events.extend(close_report.position_events); @@ -3601,7 +3723,7 @@ where execution_date, ProcessEventKind::PostAfterTrading, "after_trading:post", - CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)), + CallbackObservation::from_result(&result, Some(execution_date.and_time(after_end_time))), )?; self.observe_manual_until(&mut manual_cursor, execution_date.and_time(settlement_time.expect("settlement clock")), &mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?; @@ -3660,38 +3782,13 @@ where "settlement", CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)), )?; - let mut settlement_decision = collect_scheduled_decisions_for_stage( - &mut self.strategy, - &scheduler, - execution_date, - ScheduleStage::Settlement, - &coarse_schedule_rules, - decision_date, - decision_index, - &self.data, - &portfolio, - self.futures_account.as_ref(), - &post_close_open_orders, - self.dynamic_universe.as_ref(), - &self.subscriptions, - &mut process_events, - &mut self.process_event_bus, - result.order_events.as_slice(), - result.fills.as_slice(), - settlement_time, - settlement_time, - )?; - self.apply_strategy_directives( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &mut process_events, - &mut settlement_decision, - &mut directive_report, - &mut result, - stage_datetime(execution_date, settlement_time), + let (settlement_decision, settlement_end_time) = self.collect_lifecycle_schedules( + &scheduler, execution_date, decision_date, decision_index, ScheduleStage::Settlement, &coarse_schedule_rules, + settlement_time.expect("settlement clock"), None, settlement_time, + &mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report, )?; + decision.merge_from(settlement_decision); + settlement_time = Some(settlement_end_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); @@ -3868,7 +3965,7 @@ where if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } - result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied()); + result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied())?; result.metrics = compute_backtest_metrics_with_manual( &result.equity_curve, &result.fills, @@ -5389,6 +5486,207 @@ mod tests { assert!((result.metrics.average_daily_turnover - 6000. / 9991.).abs() < 1e-12); } + #[test] + fn opening_and_late_lifecycle_schedules_do_not_read_past_or_future_manual_state() { + struct Probe { seen: Rc>> } + impl Strategy for Probe { + fn name(&self) -> &str { "lifecycle-manual-clock" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { vec![ + ScheduleRule::daily("opening-first", ScheduleStage::OpenAuction).with_time_rule(ScheduleTimeRule::physical_time(9, 20)), + ScheduleRule::daily("opening-second", ScheduleStage::OpenAuction).with_time_rule(ScheduleTimeRule::physical_time(9, 26)), + ScheduleRule::daily("after-first", ScheduleStage::AfterTrading).with_time_rule(ScheduleTimeRule::physical_time(15, 15)), + ScheduleRule::daily("after-second", ScheduleStage::AfterTrading).with_time_rule(ScheduleTimeRule::physical_time(16, 0)), + ScheduleRule::daily("settlement", ScheduleStage::Settlement).with_time_rule(ScheduleTimeRule::physical_time(16, 10)), + ] } + fn on_day(&mut self, _: &StrategyContext<'_>) -> Result { Ok(Default::default()) } + fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule) -> Result { + self.seen.borrow_mut().push((rule.name.clone(), ctx.current_time().unwrap(), ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity))); + Ok(Default::default()) + } + } + let date = d(2026, 6, 1); + let seen = Rc::new(RefCell::new(Vec::new())); + 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) + .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 }; + BacktestEngine::new(clock_probe_data(date, &[]), Probe { seen:seen.clone() }, broker, config) + .with_observed_manual_executions(observed_manual_replay(date, &[(9,22),(15,30),(16,5)])).unwrap().run().unwrap(); + let values = seen.borrow(); + for (name, expected) in [("opening-first",0),("opening-second",100),("after-first",100),("after-second",200),("settlement",300)] { + assert_eq!(values.iter().find(|value| value.0 == name).unwrap().2, expected, "{name}: {values:?}"); + } + assert!(values.windows(2).all(|pair| pair[0].1 <= pair[1].1)); + } + + struct LateIntentProbe { + origin: NaiveDate, + complete_target: bool, + replace_with_zero: bool, + } + + impl Strategy for LateIntentProbe { + fn name(&self) -> &str { "late-intent-probe" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { + vec![ + ScheduleRule::daily("late", ScheduleStage::AfterTrading) + .with_time_rule(ScheduleTimeRule::physical_time(16, 0)), + ScheduleRule::daily("last", ScheduleStage::Settlement) + .with_time_rule(ScheduleTimeRule::physical_time(16, 10)), + ] + } + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + if self.replace_with_zero && ctx.execution_date > self.origin { + Ok(StrategyDecision { rebalance: true, ..Default::default() }) + } else { Ok(Default::default()) } + } + fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule) -> Result { + if ctx.execution_date != self.origin { return Ok(Default::default()); } + if self.complete_target { + // The settlement target replaces the earlier complete target. + Ok(StrategyDecision { rebalance: true, + target_weights: [(SYMBOL.into(), if rule.name == "late" { 0.2 } else { 0.3 })].into(), + ..Default::default() }) + } else { + Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { + symbol: SYMBOL.into(), quantity: if rule.name == "late" {100} else {200}, reason: rule.name.clone(), + }], ..Default::default() }) + } + } + } + + fn late_intent_run(end: NaiveDate, lag: usize, complete_target: bool, replace_with_zero: bool) -> super::BacktestResult { + let dates = [d(2026,6,1), d(2026,6,2), d(2026,6,3), d(2026,6,4)]; + let origin = dates[lag]; + let data = dataset_from_market_and_candidates( + dates.iter().map(|&date| market(date, if date > origin {12.} else {10.}, if date > origin {12.} else {10.})).collect(), + dates.iter().map(|&date| candidate(date)).collect(), + ); + 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(dates[0]), end_date:Some(end), decision_lag_trading_days:lag, execution_price_field:PriceField::Open }; + BacktestEngine::new(data, LateIntentProbe {origin, complete_target, replace_with_zero}, broker, config).run().unwrap() + } + + #[test] + fn late_imperative_orders_execute_once_in_next_session_with_original_dates_and_new_price() { + for lag in [0,1] { + let result = late_intent_run(d(2026,6,4), lag, false, false); + let origin = d(2026,6,1) + chrono::Duration::days(lag as i64); + let execution = origin.succ_opt().unwrap(); + assert_eq!(result.fills.len(), 2, "lag={lag}: {:?}", result.order_events); + assert_eq!(result.fills.iter().map(|fill| fill.quantity).collect::>(), vec![100,200]); + for fill in &result.fills { + assert_eq!(fill.date, execution); + assert_eq!(fill.execution_date, Some(execution)); + assert_eq!(fill.decision_date, Some(d(2026,6,1))); + assert_eq!(fill.order_created_date, Some(origin)); + assert_eq!(fill.price, 12., "must not reuse the signal-day price"); + } + assert_eq!(result.terminal_audit.deferred_strategy_decision_count, 0); + assert_eq!(result.holdings_summary[0].quantity, 300); + } + } + + #[test] + fn late_complete_targets_use_latest_target_and_next_day_zero_supersedes_them() { + let result = late_intent_run(d(2026,6,4), 0, true, false); + assert_eq!(result.fills.len(), 1); + assert_eq!(result.fills[0].quantity, 200); + assert_eq!(result.fills[0].price, 12.); + let zero = late_intent_run(d(2026,6,4), 0, true, true); + assert!(zero.fills.is_empty(), "an obsolete buy must not run before the current zero target"); + assert!(zero.holdings_summary.is_empty()); + assert_eq!(zero.terminal_audit.deferred_strategy_decision_count, 0); + } + + #[test] + fn final_day_late_intents_are_retained_without_orders_or_fills_past_the_end() { + let result = late_intent_run(d(2026,6,1), 0, false, false); + assert!(result.fills.is_empty()); + assert!(result.order_events.is_empty()); + let audit = &result.terminal_audit; + assert_eq!(audit.status, super::BacktestTerminalStatus::CompletedWithPendingState); + assert_eq!(audit.deferred_strategy_decision_count, 2); + assert_eq!(audit.deferred_strategy_decisions.len(), 2); + assert_eq!(audit.deferred_strategy_decisions[0]["created_at"], "2026-06-01T16:00:00"); + assert_eq!(audit.deferred_strategy_decisions[1]["decision"]["order_intents"][0]["Shares"]["quantity"], 200); + let latest = late_intent_run(d(2026,6,1), 0, true, false); + assert_eq!(latest.terminal_audit.deferred_strategy_decision_count, 1); + assert_eq!(latest.terminal_audit.deferred_strategy_decisions[0]["decision"]["target_weights"][SYMBOL], 0.3); + } + + #[test] + fn late_intents_do_not_wait_for_another_selection_snapshot() { + let dates = [d(2026,6,1), d(2026,6,2), d(2026,6,3)]; + let data = DataSet::from_components(vec![default_instrument()], + dates.iter().map(|&date| market(date, if date == dates[0] {10.} else {12.}, 12.)).collect(), + vec![factor(dates[0]), factor(dates[2])], + dates.iter().map(|&date| candidate(date)).collect(), + dates.iter().map(|&date| benchmark(date)).collect()).unwrap(); + 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(dates[0]), end_date:Some(dates[2]), decision_lag_trading_days:0, execution_price_field:PriceField::Open }; + let strategy = LateIntentProbe {origin:dates[0], complete_target:false, replace_with_zero:false}; + assert_eq!(super::backtest_execution_dates_with_rules(&data, Some(dates[0]), Some(dates[2]), 0, &strategy.schedule_rules()), dates); + let result = BacktestEngine::new(data, strategy, broker, config).run().unwrap(); + assert_eq!(result.fills.len(), 2); + assert!(result.fills.iter().all(|fill| fill.date == dates[1] && fill.price == 12.)); + assert!(result.equity_curve.iter().any(|point| point.date == dates[1] && point.diagnostics.contains("deferred_strategy_session_no_new_signal"))); + assert_eq!(result.terminal_audit.deferred_strategy_decision_count, 0); + } + + #[test] + fn opening_schedule_cannot_advance_past_the_intraday_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))] + } + } + 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")); + } + + #[test] + fn after_trading_cancel_is_executed_as_a_control_not_a_next_session_order() { + struct LateCancel; + impl Strategy for LateCancel { + fn name(&self) -> &str { "late-cancel" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { + vec![ScheduleRule::daily("cancel", ScheduleStage::AfterTrading) + .with_time_rule(ScheduleTimeRule::physical_time(16,0))] + } + fn on_day(&mut self, _: &StrategyContext<'_>) -> Result { + Ok(StrategyDecision { order_intents: vec![OrderIntent::LimitShares { + symbol:SYMBOL.into(), quantity:100, limit_price:5., reason:"resting".into(), + }.with_time_in_force(crate::strategy::OrderTimeInForce::Gtc)], ..Default::default() }) + } + fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, _: &ScheduleRule) -> Result { + assert_eq!(ctx.open_orders.len(), 1); + Ok(StrategyDecision { order_intents: vec![OrderIntent::CancelAll {reason:"late-cancel".into()}], ..Default::default() }) + } + } + let date = d(2026,6,1); + 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 result = BacktestEngine::new(clock_probe_data(date, &[]), LateCancel, broker, config).run().unwrap(); + assert!(result.fills.is_empty()); + assert!(result.order_events.iter().any(|order| order.status == OrderStatus::Canceled && order.reason.contains("late-cancel"))); + assert_eq!(result.terminal_audit.stock_open_order_count, 0); + assert_eq!(result.terminal_audit.deferred_strategy_decision_count, 0); + } + #[test] fn early_pre_open_schedules_observe_manual_receipts_in_chronological_order() { struct Probe { seen: Rc>> } diff --git a/crates/fidc-core/src/finite_serialization.rs b/crates/fidc-core/src/finite_serialization.rs new file mode 100644 index 0000000..c2af8f0 --- /dev/null +++ b/crates/fidc-core/src/finite_serialization.rs @@ -0,0 +1,196 @@ +//! Check typed pending intent numbers before JSON could replace NaN/Inf with null. +//! This traverses the original Serialize representation without materializing it. +use serde::{Serialize, Serializer, ser}; + +#[derive(Clone, Copy)] +struct Finite; + +pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> { + value.serialize(Finite) +} + +macro_rules! scalar { + ($($method:ident: $ty:ty),* $(,)?) => {$( + fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) } + )*}; +} + +impl Serializer for Finite { + type Ok = (); + type Error = serde_json::Error; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = Self; + type SerializeMap = Self; + type SerializeStruct = Self; + type SerializeStructVariant = Self; + + scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16, + serialize_i32: i32, serialize_i64: i64, serialize_i128: i128, + serialize_u8: u8, serialize_u16: u16, serialize_u32: u32, + serialize_u64: u64, serialize_u128: u128, serialize_char: char, + serialize_str: &str, serialize_bytes: &[u8]); + fn serialize_f32(self, value: f32) -> Result<(), Self::Error> { + self.serialize_f64(f64::from(value)) + } + fn serialize_f64(self, value: f64) -> Result<(), Self::Error> { + if value.is_finite() { + Ok(()) + } else { + Err(ser::Error::custom( + "pending strategy intent contains a non-finite number", + )) + } + } + fn serialize_none(self) -> Result<(), Self::Error> { + Ok(()) + } + fn serialize_some(self, value: &T) -> Result<(), Self::Error> { + value.serialize(self) + } + fn serialize_unit(self) -> Result<(), Self::Error> { + Ok(()) + } + fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> { + Ok(()) + } + fn serialize_unit_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + ) -> Result<(), Self::Error> { + Ok(()) + } + fn serialize_newtype_struct( + self, + _: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + value.serialize(self) + } + fn serialize_newtype_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + value.serialize(self) + } + fn serialize_seq(self, _: Option) -> Result { + Ok(self) + } + fn serialize_tuple(self, _: usize) -> Result { + Ok(self) + } + fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result { + Ok(self) + } + fn serialize_tuple_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + Ok(self) + } + fn serialize_map(self, _: Option) -> Result { + Ok(self) + } + fn serialize_struct(self, _: &'static str, _: usize) -> Result { + Ok(self) + } + fn serialize_struct_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + Ok(self) + } +} + +macro_rules! sequence { + ($trait:ident, $method:ident) => { + impl ser::$trait for Finite { + type Ok = (); + type Error = serde_json::Error; + fn $method(&mut self, value: &T) -> Result<(), Self::Error> { + value.serialize(*self) + } + fn end(self) -> Result<(), Self::Error> { + Ok(()) + } + } + }; +} +sequence!(SerializeSeq, serialize_element); +sequence!(SerializeTuple, serialize_element); +sequence!(SerializeTupleStruct, serialize_field); +sequence!(SerializeTupleVariant, serialize_field); + +impl ser::SerializeMap for Finite { + type Ok = (); + type Error = serde_json::Error; + fn serialize_key(&mut self, value: &T) -> Result<(), Self::Error> { + value.serialize(*self) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + value.serialize(*self) + } + fn end(self) -> Result<(), Self::Error> { + Ok(()) + } +} + +macro_rules! structure { + ($trait:ident) => { + impl ser::$trait for Finite { + type Ok = (); + type Error = serde_json::Error; + fn serialize_field( + &mut self, + _: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + value.serialize(*self) + } + fn end(self) -> Result<(), Self::Error> { + Ok(()) + } + } + }; +} +structure!(SerializeStruct); +structure!(SerializeStructVariant); + +#[cfg(test)] +mod tests { + use super::*; + use crate::strategy::{OrderIntent, StrategyDecision}; + + #[test] + fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let decision = StrategyDecision { + order_intents: vec![ + OrderIntent::LimitTargetPercent { + symbol: "000001.SZ".into(), + target_percent: 0.5, + limit_price: value, + reason: "test".into(), + } + .with_time_in_force(crate::strategy::OrderTimeInForce::Day), + ], + ..Default::default() + }; + assert!(validate(&decision).is_err()); + assert!(validate(&vec![Some(value)]).is_err()); + } + assert!(validate(&(None::, vec![0., -0., 0.123456789], "NaN")).is_ok()); + } +} diff --git a/crates/fidc-core/src/futures.rs b/crates/fidc-core/src/futures.rs index 07b4cc6..04f69a2 100644 --- a/crates/fidc-core/src/futures.rs +++ b/crates/fidc-core/src/futures.rs @@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator, label: &str) }) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] pub enum FuturesDirection { Long, Short, @@ -62,7 +62,7 @@ impl FuturesDirection { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum FuturesPositionEffect { Open, Close, @@ -81,7 +81,7 @@ impl FuturesPositionEffect { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] pub struct FuturesContractSpec { pub contract_multiplier: f64, pub long_margin_rate: f64, @@ -190,7 +190,7 @@ impl FuturesTransactionCostModel { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct FuturesOrderIntent { pub symbol: String, pub direction: FuturesDirection, diff --git a/crates/fidc-core/src/holding_policy.rs b/crates/fidc-core/src/holding_policy.rs index 760b60a..274b87d 100644 --- a/crates/fidc-core/src/holding_policy.rs +++ b/crates/fidc-core/src/holding_policy.rs @@ -63,7 +63,7 @@ pub struct HoldingLifecycleEvidence { pub last_sell_date: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct AutomaticTradePermission { pub buy_denial: Option<&'static str>, pub sell_denial: Option<&'static str>, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index c3d8a59..689d522 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod engine; pub mod event_bus; pub mod events; pub mod fixed_point; +mod finite_serialization; pub mod futures; pub mod instrument; pub mod metrics; @@ -62,7 +63,7 @@ pub use engine::{ AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, - ProcessEventRetention, backtest_execution_dates, + ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules, }; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use events::{ diff --git a/crates/fidc-core/src/stock_pool_execution.rs b/crates/fidc-core/src/stock_pool_execution.rs index 5f908d8..cf96e64 100644 --- a/crates/fidc-core/src/stock_pool_execution.rs +++ b/crates/fidc-core/src/stock_pool_execution.rs @@ -479,7 +479,7 @@ pub struct StockPoolSelection { pub generation: Option, } -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct StockPoolDecisionConstraints { pub execution_date: Option, pub frozen_positions: BTreeMap, @@ -545,7 +545,7 @@ pub struct StockPoolPlan { /// A signal-time contract. Only the broker/execution adapter supplies later /// prices, actual cash and holdings; strategy code never sees those inputs. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct FrozenStockPoolIntent { pub pool_id: String, pub signal_date: NaiveDate, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 5c6285c..c2bd7d0 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -983,7 +983,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 { } } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct StrategyDecision { pub buy_denials: BTreeMap, pub rebalance: bool, @@ -1101,13 +1101,13 @@ mod decision_merge_tests { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum AlgoOrderStyle { Vwap, Twap, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum OrderTimeInForce { Day, Ioc, @@ -1136,7 +1136,7 @@ impl OrderTimeInForce { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub enum TargetPortfolioOrderPricing { LimitPrices(BTreeMap), AlgoOrder { @@ -1146,7 +1146,7 @@ pub enum TargetPortfolioOrderPricing { }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub enum OrderIntent { StockPool { contract: Box, diff --git a/docs/manual-execution-clock-20260914.md b/docs/manual-execution-clock-20260914.md index 7f58367..2ab4a70 100644 --- a/docs/manual-execution-clock-20260914.md +++ b/docs/manual-execution-clock-20260914.md @@ -19,13 +19,24 @@ ## 当前验证 -Core864通过(9项原ignore不计通过)。新增四个引擎用例验证默认阶段、同刻两次回报、08:50/09:10盘前交错、手工卖后自然买入风控,以及空手工来源的自然委托/成交/权益等价。默认阶段样例最终600股、现金3991、权益9991、出入金0;不是实际券商行情验收。 +Core872通过(9项原ignore不计通过),交易工作区619普通测试通过;不是实际券商行情验收。此前默认阶段样例最终600股、现金3991、权益9991、出入金0,原四个基础用例保留。 + +本轮新增8项回归,不重复把基础样例当新验收: + +- OpenAuction 09:20/09:26、AfterTrading 15:15/16:00、Settlement 16:10与09:22/15:30/16:05手工观察交错。原候选09:20提前读到100股,16:00/16:10仍只读到100股;修复后依次为0/100/100/200/300股。 +- 盘后16:00的100股与结算16:10的200股显式指令,下一交易日各执行一次、共300股,信号日价格10不冒充执行日价格12。lag0/lag1保留原信号日、意图创建日和实际成交日;测试还抓到立即成交记录曾被统一注释为新信号日,已按批次原始日期记录。 +- 多个完整目标在进入待执行队列时就只保留最新一份;次日新的0%完整目标不会先执行旧买入。显式股数指令不作为完整目标覆盖。 +- 结束日期的两笔显式意图没有生成委托/成交,完整原指令留在terminalAudit;完整目标只留最新一份。NaN/Inf不能在JSON中被悄悄变成null。 +- 存在真实行情/风控但没有新因子选股快照的下一交易日,仍执行已有指令,不等到后日再运行。声明盘后阶段的策略使用完整市场日历,外部指标可用同一`backtest_execution_dates_with_rules`对齐;当前Runner的Platform策略只暴露OpenAuction/OnDay/Minute,不宣称已支持配置盘后阶段。 +- 显式开盘调度越过已配置执行窗口被拒绝;盘后GTC撤单立即作为控制执行,不变成次日新委托。 + +盘后处理使用正常账本/报价/风控入口,不创建模拟外部Fill,不越过结束日期。旧DAY订单仍按到期失效,下一日处理的是尚未提交的策略意图,并非延长旧订单有效期。无新信号的报价时钟复用有序迭代器,不复制整日Tick列表。 结果协议和API/Runner的候选接入见fidc-backtest-service/docs/manual-execution-run-contract-20260914.md。当前正常记录/费用原始精度不改;所有新代码尚未发布,影子调用仍没有解除四类纯比例拒绝门禁。 ## 必须继续 -1. 显式Opening/AfterTrading/Settlement调度的完整跨阶段交错、会话外未提交意图、待执行目标冲突与公司行为/跨日组合仍需完成,不把本轮默认阶段样例当全日历证明。 +1. 本轮已覆盖上述显式阶段与跨日用例;仍需补完整混合时钟矩阵,特别是显式开盘晚于盘中报价/ETF开盘、无新信号日同时有ETF待执行目标、公司行为和跨日保护组合。不得修改market_open已有09:31语义或把这些未验组合静默跳过以让测试通过。 2. 完成影子调度调用、所需历史证券范围、来源权限/归属、实际HTTP和Linux验收;不以独立输入/结果单测冒充端到端。 3. 结果委托/成交分页接口与统一UI仍须合并展示外部手工来源,保留未知组件和完整原始ID,不把仅落库视为呈现已完成。 4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。