From 7f0c6a008a2b82578e96ad5b2033002941060d9c Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 11:41:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E6=89=8B=E5=B7=A5=E8=A7=82=E5=AF=9F?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=89=A7=E8=A1=8C=E6=97=B6=E9=92=9F=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=9D=A5=E6=BA=90=E4=B8=8E=E8=B4=A6=E6=9C=AC?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 7 + crates/fidc-core/src/engine.rs | 418 ++++++++++++++---- crates/fidc-core/src/events.rs | 3 + crates/fidc-core/src/manual_execution.rs | 34 +- crates/fidc-core/src/metrics.rs | 19 +- .../fidc-core/src/platform_expr_strategy.rs | 6 + crates/fidc-core/src/scheduler.rs | 2 +- crates/fidc-core/src/strategy.rs | 6 + docs/manual-execution-clock-20260914.md | 33 ++ docs/manual-replay-foundation-20260914.md | 2 + 10 files changed, 435 insertions(+), 95 deletions(-) create mode 100644 docs/manual-execution-clock-20260914.md diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 081f14d..0c9e191 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2754,6 +2754,13 @@ where .insert(symbol.to_string()); } + pub(crate) fn record_observed_manual_execution(&self, execution: &crate::manual_execution::ManualReplayApplication) { + if execution.side == OrderSide::Sell { + let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive(); + self.mark_same_day_sold(date, &execution.symbol); + } + } + fn same_day_rebuy_rejection_reason( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 40547f4..f1cfb9f 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -16,7 +16,7 @@ use crate::futures::{ FuturesAccountState, FuturesExecutionReport, FuturesOrderIntent, FuturesPositionEffect, FuturesTransactionCostModel, }; -use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics}; +use crate::metrics::{BacktestMetrics, RiskFreeRateContract, compute_backtest_metrics_with_manual}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope}; use crate::rules::EquityRuleHooks; @@ -128,6 +128,8 @@ pub struct BacktestResult { pub risk_decisions: Vec, pub order_events: Vec, pub fills: Vec, + pub manual_executions: Vec, + pub manual_execution_source: Option>, pub position_events: Vec, pub account_events: Vec, pub process_events: Vec, @@ -437,6 +439,8 @@ pub struct BacktestDayProgress { pub diagnostics: String, pub orders: Vec, pub fills: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub manual_executions: Vec, pub holdings: Vec, pub process_events: Vec, } @@ -477,6 +481,7 @@ pub struct BacktestEngine { execution_absence_notes: BTreeMap>, execution_lifecycle_reported: BTreeSet<(String, String)>, risk_free_rate_contract: Option, + manual_execution_source: Option>, } fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool { @@ -576,9 +581,16 @@ impl BacktestEngine { execution_absence_notes: BTreeMap::new(), execution_lifecycle_reported: BTreeSet::new(), risk_free_rate_contract: None, + manual_execution_source: None, } } + pub fn with_observed_manual_executions(mut self, replay: crate::manual_execution::ManualExecutionReplay) -> Result { + replay.validate().map_err(BacktestError::Execution)?; + self.manual_execution_source = Some(std::sync::Arc::new(replay)); + Ok(self) + } + pub fn with_risk_free_rate_contract(mut self, contract: RiskFreeRateContract) -> Self { self.risk_free_rate_contract = Some(contract); self @@ -722,6 +734,88 @@ where C: CostModel, R: EquityRuleHooks, { + fn observe_manual_until( + &mut self, + cursor: &mut Option, + through: chrono::NaiveDateTime, + portfolio: &mut PortfolioState, + result: &mut BacktestResult, + process_events: &mut Vec, + decision: Option<(NaiveDate, usize)>, + ) -> Result<(), BacktestError> { + let Some(cursor) = cursor.as_mut() else { return Ok(()); }; + let shanghai = chrono::FixedOffset::east_opt(8 * 3600).unwrap(); + while let Some(at) = cursor.next_observation_at() { + let observed = at.with_timezone(&shanghai).naive_local(); + if observed > through { break; } + let cash_before = portfolio.cash(); + let conflict = self.has_open_orders() || self.broker.has_pending_stock_pool_execution() + || self.broker.pending_etf_target_count() > 0; + let application = cursor.advance_next(portfolio, &self.data, conflict) + .map_err(BacktestError::Execution)?.expect("next observation checked"); + self.broker.record_observed_manual_execution(&application); + self.strategy.on_observed_manual_execution(&application)?; + result.account_events.push(AccountEvent { date: observed.date(), cash_before, + cash_after: portfolio.cash(), total_equity: portfolio.total_equity(), + note: format!("observed_manual_execution action_id={} order_id={} trade_id={}", application.action_id, application.order_id, application.trade_id) }); + let event = ProcessEvent { date: observed.date(), kind: ProcessEventKind::ManualExecutionObserved, + order_id: None, symbol: Some(application.symbol.clone()), side: Some(application.side), + detail: serde_json::to_string(&application).map_err(|error| BacktestError::Execution(error.to_string()))? }; + if let Some((decision_date, decision_index)) = decision { + let orders = self.open_order_views(); + publish_custom_process_event(&mut self.strategy, &mut self.process_event_bus, + observed.date(), decision_date, decision_index, &self.data, portfolio, + self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions, + process_events, event, CallbackObservation::from_result(result, Some(observed)))?; + } else { + self.process_event_bus.publish(&event); + process_events.push(event); + } + result.manual_executions.push(application); + } + Ok(()) + } + + fn collect_before_trading_schedules( + &mut self, scheduler: &Scheduler<'_>, execution_date: NaiveDate, decision_date: NaiveDate, + decision_index: usize, rules: &[ScheduleRule], 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 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, + 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, + &mut decision, directive_report, result, Some(execution_date.and_time(time)))?; + let (controls, deferred): (Vec<_>, Vec<_>) = std::mem::take(&mut decision.order_intents).into_iter().partition(|intent| + matches!(intent.unwrapped(), OrderIntent::CancelOrder {..} | OrderIntent::CancelSymbol {..} | OrderIntent::CancelAll {..} | OrderIntent::ModifyOrder {..})); + decision.order_intents = deferred; + if !controls.is_empty() { + let control = StrategyDecision { order_intents: controls, buy_denials: decision.buy_denials.clone(), risk_decisions: decision.risk_decisions.clone(), ..Default::default() }; + let mut report = self.broker.execute_controls_without_matching(execution_date, decision_date, portfolio, &self.data, &control, Some(time))?; + Self::record_execution_history(result, &mut 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, + events, &mut report.process_events, CallbackObservation::from_result(result, Some(execution_date.and_time(time))))?; + merge_broker_report(directive_report, report); + } + combined.merge_from(decision); + } + Ok((combined, phase_end)) + } + fn ensure_execution_quotes_for_decision( &mut self, execution_date: NaiveDate, @@ -2441,6 +2535,9 @@ where F: FnMut(&BacktestDayProgress), { let mut portfolio = PortfolioState::new(self.config.initial_cash); + let mut manual_cursor = self.manual_execution_source.as_ref().map(|source| + crate::manual_execution::ManualReplayCursor::from_shared(source.clone())).transpose().map_err(BacktestError::Execution)?; + let manual_has_fills = manual_cursor.as_ref().is_some_and(|cursor| cursor.next_observation_at().is_some()); self.subscriptions = self.strategy.initial_subscriptions(); let scheduler_calendar = self.data.calendar().clone(); let scheduler = Scheduler::new(&scheduler_calendar); @@ -2454,6 +2551,11 @@ where .iter() .map(|(execution_date, _)| *execution_date) .collect::>(); + if let (Some(first), Some(observed)) = (execution_dates.first(), manual_cursor.as_ref().and_then(|cursor| cursor.next_observation_at())) { + if observed.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive() < *first { + return Err(BacktestError::Execution("manual observations precede the declared initial portfolio period".into())); + } + } let mut result = BacktestResult { capacity_audit: self.broker.capacity_audit_summary(), strategy_name: self.strategy.name().to_string(), @@ -2477,6 +2579,8 @@ where risk_decisions: Vec::new(), order_events: Vec::new(), fills: Vec::new(), + manual_executions: Vec::new(), + manual_execution_source: self.manual_execution_source.clone(), position_events: Vec::new(), account_events: Vec::new(), process_events: Vec::new(), @@ -2491,6 +2595,12 @@ where for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); + // Non-trading-day receipts between two sessions must precede the + // next session's corporate actions. They do not create market bars. + let mut between_session_events = Vec::new(); + self.observe_manual_until(&mut manual_cursor, execution_date.and_hms_opt(0, 0, 0).unwrap(), + &mut portfolio, &mut result, &mut between_session_events, None)?; + self.retain_process_events(&mut result.process_events, &mut between_session_events); portfolio.begin_trading_day(); if let Some(account) = self.futures_account.as_mut() { account.begin_trading_day(); @@ -2548,6 +2658,7 @@ where ); let day_order_start = result.order_events.len(); let day_fill_start = result.fills.len(); + let day_manual_start = result.manual_executions.len(); let decision_slot = execution_schedule .get(execution_idx) @@ -2555,6 +2666,8 @@ where 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)?; + self.observe_manual_until(&mut manual_cursor, execution_date.and_hms_nano_opt(23, 59, 59, 999_999_999).unwrap(), + &mut portfolio, &mut result, &mut process_events, None)?; portfolio.update_prices_with_options( execution_date, &self.data, @@ -2573,7 +2686,7 @@ where risk_decisions_from_order_events(&report.order_events); self.extend_result(&mut result, report, execution_date, execution_date); result.risk_decisions.extend(execution_risk_decisions); - let daily_fill_count = result.fills.len() - day_fill_start; + let daily_fill_count = result.fills.len() - day_fill_start + result.manual_executions.len() - day_manual_start; let daily_order_count = result.order_events.len() - day_order_start; let benchmark = @@ -2633,7 +2746,7 @@ where benchmark_close: latest.benchmark_close, daily_fill_count, daily_order_count, - cumulative_trade_count: result.fills.len(), + cumulative_trade_count: result.fills.len() + result.manual_executions.len(), holding_count, notes: include_progress_diagnostics .then(|| latest.notes.clone()) @@ -2647,6 +2760,7 @@ where fills: include_progress_details .then(|| result.fills[day_fill_start..].to_vec()) .unwrap_or_default(), + manual_executions: include_progress_details.then(|| result.manual_executions[day_manual_start..].to_vec()).unwrap_or_default(), holdings: include_progress_details .then(|| result.daily_holdings[holding_start..].to_vec()) .unwrap_or_default(), @@ -2662,8 +2776,14 @@ where .flatten(); let mut process_events = Vec::new(); let mut directive_report = BrokerExecutionReport::default(); - let pre_open_orders = self.open_order_views(); let schedule_rules = self.strategy.schedule_rules(); + 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(); + 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)))?; + let pre_open_orders = self.open_order_views(); let keep_timed_rules_on_coarse_stage = self.broker.matching_type() == MatchingType::NextBarOpen; let coarse_schedule_rules = schedule_rules @@ -2698,7 +2818,7 @@ where execution_date, ProcessEventKind::PreBeforeTrading, "before_trading:pre", - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), + CallbackObservation::from_result(&result, Some(execution_date.and_time(before_start_time))), )?; self.strategy.before_trading(&StrategyContext { execution_date, @@ -2714,7 +2834,7 @@ where active_process_event: None, active_datetime: stage_datetime( decision_date, - default_stage_time(ScheduleStage::BeforeTrading), + Some(before_start_time), ), order_events: result.order_events.as_slice(), fills: result.fills.as_slice(), @@ -2735,57 +2855,12 @@ where execution_date, ProcessEventKind::BeforeTrading, "before_trading", - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), + CallbackObservation::from_result(&result, Some(execution_date.and_time(before_start_time))), )?; - let mut before_trading_decision = collect_scheduled_decisions_for_stage( - &mut self.strategy, - &scheduler, - execution_date, - ScheduleStage::BeforeTrading, - &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::BeforeTrading), + let (before_trading_decision, before_end_time) = self.collect_before_trading_schedules( + &scheduler, execution_date, decision_date, decision_index, &coarse_schedule_rules, + &mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report, )?; - self.apply_strategy_directives( - execution_date, - decision_date, - decision_index, - &mut portfolio, - &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, @@ -2803,8 +2878,11 @@ where execution_date, ProcessEventKind::PostBeforeTrading, "before_trading:post", - CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::BeforeTrading))), + 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")), + &mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -2917,7 +2995,7 @@ where 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() || pending_portfolio.is_some() || !pre_day_batches.is_empty()).then(|| match self.broker.matching_type() { + (original_minute_clock || manual_has_fills || deferred_etf_time.is_some() || pending_portfolio.is_some() || !pre_day_batches.is_empty()).then(|| match self.broker.matching_type() { MatchingType::CurrentBarClose => NaiveTime::from_hms_opt(15, 0, 0).unwrap(), _ => NaiveTime::from_hms_opt(9, 30, 0).unwrap(), }) @@ -2984,7 +3062,7 @@ where 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() + if original_minute_clock || manual_has_fills || deferred_day_time.is_some() || ((self.broker.has_open_orders() || self.broker.has_pending_stock_pool_execution()) && self.broker.drives_resting_quote_clock()) { let unfiltered_minute_stream = self.subscriptions.is_empty(); @@ -3046,19 +3124,27 @@ where let next_expiry_timestamp = self.broker.next_day_order_expiry(execution_date) .map(|time| execution_date.and_time(time)) .filter(|time| last_minute_timestamp.is_none_or(|last| last < *time)); - let Some(minute_timestamp) = - next_minute_event_timestamp( + let next_natural_timestamp = next_minute_event_timestamp( next_minute_event_timestamp(next_quote_timestamp, next_schedule_timestamp), next_minute_event_timestamp(next_expiry_timestamp, next_minute_event_timestamp(deferred_day_time.map(|time| execution_date.and_time(time)), deferred_etf_time.map(|time|execution_date.and_time(time)))), - ) + ); + let manual_phase_end = default_stage_time(ScheduleStage::AfterTrading).into_iter() + .chain(self.broker.post_close_execution_quote_window_for_order(execution_date, execution_date, last_execution_time).map(|(_, end)| end)) + .chain(next_natural_timestamp.map(|time| time.time())).max().expect("after-trading clock"); + let next_manual_timestamp = manual_cursor.as_ref().and_then(|cursor| cursor.next_observation_at()) + .map(|at| at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).naive_local()) + .filter(|at| at.date() == execution_date && at.time() <= manual_phase_end); + let Some(minute_timestamp) = next_minute_event_timestamp(next_natural_timestamp, next_manual_timestamp) else { break; }; let minute_time = minute_timestamp.time(); last_minute_timestamp = Some(minute_timestamp); - last_execution_time = Some(minute_time); + if next_natural_timestamp == Some(minute_timestamp) { last_execution_time = Some(minute_time); } + self.observe_manual_until(&mut manual_cursor, minute_timestamp, &mut portfolio, &mut result, + &mut process_events, Some((decision_date, decision_index)))?; minute_group.clear(); while minute_quotes .peek() @@ -3360,6 +3446,8 @@ where .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.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)))?; 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); @@ -3515,6 +3603,8 @@ where "after_trading:post", CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_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)))?; publish_phase_event( &mut self.strategy, &mut self.process_event_bus, @@ -3663,7 +3753,14 @@ where decision_date, execution_date, ); - let daily_fill_count = result.fills.len() - day_fill_start; + 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, Some((decision_date, decision_index)))?; + if result.manual_executions.last().is_some_and(|execution| + execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive() == execution_date) { + portfolio.update_prices_with_options(execution_date, &self.data, PriceField::Close, + self.broker.same_day_buy_close_mark_at_fill())?; + } + let daily_fill_count = result.fills.len() - day_fill_start + result.manual_executions.len() - day_manual_start; for audit in self.broker.audit_completed_session_capacity(execution_date, &self.data)? { result.capacity_audit.observe(&audit); // Keep every audit in the durable event store, independent of @@ -3740,7 +3837,7 @@ where benchmark_close: latest.benchmark_close, daily_fill_count, daily_order_count, - cumulative_trade_count: result.fills.len(), + cumulative_trade_count: result.fills.len() + result.manual_executions.len(), holding_count, notes: include_progress_diagnostics .then(|| latest.notes.clone()) @@ -3754,6 +3851,7 @@ where fills: include_progress_details .then(|| result.fills[day_fill_start..].to_vec()) .unwrap_or_default(), + manual_executions: include_progress_details.then(|| result.manual_executions[day_manual_start..].to_vec()).unwrap_or_default(), holdings: include_progress_details .then(|| result.daily_holdings[holding_start..].to_vec()) .unwrap_or_default(), @@ -3764,13 +3862,17 @@ where stock_equity_by_date.insert(execution_date, portfolio.total_equity()); } + if manual_cursor.as_ref().is_some_and(|cursor| cursor.next_observation_at().is_some()) { + return Err(BacktestError::Execution("manual observations extend beyond the represented execution calendar".into())); + } 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.metrics = compute_backtest_metrics( + result.metrics = compute_backtest_metrics_with_manual( &result.equity_curve, &result.fills, + &result.manual_executions, &result.daily_holdings, &result.account_events, self.aggregate_initial_cash(), @@ -4656,26 +4758,7 @@ fn collect_scheduled_decisions_for_stage( default_time_override: Option, physical_time: Option, ) -> Result { - let mut times = BTreeSet::new(); - for rule in rules.iter().filter(|rule| rule.stage == stage) { - let time = match rule.time_rule.as_ref() { - Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(value)) => { - let hour = value / 60; - let minute = value % 60; - Some(NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| { - BacktestError::Execution(format!( - "invalid schedule minute-of-day {} for rule {}", - value, rule.name - )) - })?) - } - Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) => { - default_stage_time(stage) - } - None => default_time_override.or_else(|| default_stage_time(stage)), - }; - times.insert(time); - } + let times = scheduled_stage_times(stage, rules, default_time_override)?; let mut combined = crate::strategy::StrategyDecision::default(); for time in times { combined.merge_from(collect_scheduled_decisions( @@ -4703,6 +4786,30 @@ fn collect_scheduled_decisions_for_stage( Ok(combined) } +fn scheduled_stage_times(stage: ScheduleStage, rules: &[ScheduleRule], default_time_override: Option) -> Result>, BacktestError> { + let mut times = BTreeSet::new(); + for rule in rules.iter().filter(|rule| rule.stage == stage) { + let time = match rule.time_rule.as_ref() { + Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(value)) => { + let hour = value / 60; + let minute = value % 60; + Some(NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| { + BacktestError::Execution(format!( + "invalid schedule minute-of-day {} for rule {}", + value, rule.name + )) + })?) + } + Some(crate::scheduler::ScheduleTimeRule::BeforeTrading) => { + default_stage_time(stage) + } + None => default_time_override.or_else(|| default_stage_time(stage)), + }; + times.insert(time); + } + Ok(times) +} + #[derive(Clone, Copy)] struct CallbackObservation<'a> { datetime: Option, @@ -5210,6 +5317,147 @@ mod tests { const SYMBOL: &str = "000001.SZ"; + fn observed_manual_replay(date: NaiveDate, times: &[(u32, u32)]) -> crate::manual_execution::ManualExecutionReplay { + use crate::manual_execution::*; + let utc = |local: NaiveDateTime| chrono::DateTime::::from_naive_utc_and_offset(local - chrono::Duration::hours(8), chrono::Utc); + let actions = times.iter().enumerate().map(|(index, &(hour, minute))| { + let observed = date.and_hms_opt(hour, minute, 0).unwrap(); + let executed = if hour < 9 || (hour == 9 && minute < 25) { + date.pred_opt().unwrap().and_hms_opt(15, 20, 0).unwrap() + } else { observed.min(date.and_hms_opt(15, 20, 0).unwrap()) }; + let created = utc(executed - chrono::Duration::seconds(1)); + ManualExecutionAction { action_id: format!("manual-action-{index}"), source: ManualExecutionSource::ManualSecurityTrade, + audit_event_ids: vec![format!("manual-audit-{index}")], confirmed_at: created, confirmation_observed_at: created, + outcome: ManualActionOutcome::OrdersTerminal, orders: vec![ManualExecutionOrder { + order_id: format!("manual-order-{index}"), broker_order_id: Some(format!("broker-order-{index}")), source_adapter: Some("gt-api".into()), + symbol: SYMBOL.into(), side: OrderSide::Buy, quantity: 100, order_created_at: created, + terminal_observed_at: utc(observed), terminal_status: ManualOrderTerminalStatus::Filled, + fills: vec![ManualExecutionFill { trade_id: format!("manual-trade-{index}"), observation_event_id: format!("manual-receipt-{index}"), + observation_sequence: index as u64 + 1, fee_observation_event_id: format!("manual-receipt-{index}"), fee_observation_sequence: index as u64 + 1, + fee_observed_at: utc(observed), trade_date: executed.date(), executed_at: utc(executed), observed_at: utc(observed), + timestamp_precision: ManualTimestampPrecision::Second, quantity: 100, price: 10.into(), commission: Some("1.5".parse().unwrap()), + stamp_tax: None, transfer_fee: None, total_fee: "1.5".parse().unwrap() }], + }] } + }).collect(); + let mut replay = ManualExecutionReplay { schema: MANUAL_REPLAY_SCHEMA.into(), runtime_id: "manual-runtime".into(), account_id: "manual-account".into(), + source_contract_sha256: "a".repeat(64), content_sha256: String::new(), observation_cutoff: utc(date.and_hms_opt(23, 59, 59).unwrap()), actions }; + replay.content_sha256 = replay.content_digest().unwrap(); + replay.validate().unwrap(); + replay + } + + #[test] + fn manual_observations_are_visible_at_their_real_phase_and_each_receipt_sequence() { + struct Probe { seen: Rc>> } + impl Probe { + fn record(&self, name: &str, ctx: &StrategyContext<'_>) { + self.seen.borrow_mut().push((name.into(), ctx.current_time().unwrap(), ctx.portfolio.position(SYMBOL).map_or(0, |position| position.quantity))); + } + } + impl Strategy for Probe { + fn name(&self) -> &str { "manual-clock-probe" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { vec![ScheduleRule::daily("check", ScheduleStage::Minute).with_time_rule(ScheduleTimeRule::physical_time(11, 0))] } + fn before_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), crate::BacktestError> { self.record("before", ctx); Ok(()) } + fn open_auction(&mut self, ctx: &StrategyContext<'_>) -> Result { self.record("auction", ctx); Ok(Default::default()) } + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { self.record("day", ctx); Ok(Default::default()) } + fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, _: &ScheduleRule) -> Result { self.record("scheduled", ctx); Ok(Default::default()) } + fn after_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), crate::BacktestError> { self.record("after", ctx); Ok(()) } + fn on_settlement(&mut self, ctx: &StrategyContext<'_>) -> Result<(), crate::BacktestError> { self.record("settlement", ctx); Ok(()) } + fn on_observed_manual_execution(&mut self, execution: &crate::manual_execution::ManualReplayApplication) -> Result<(), crate::BacktestError> { + self.seen.borrow_mut().push(("manual".into(), execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).time(), execution.quantity_after)); Ok(()) + } + } + let date = d(2026, 6, 1); + let seen = Rc::new(RefCell::new(Vec::new())); + let data = clock_probe_data(date, &[]); + 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 replay = observed_manual_replay(date, &[(9, 0), (9, 20), (9, 27), (10, 0), (10, 0), (16, 30)]); + let result = BacktestEngine::new(data, Probe { seen: seen.clone() }, broker, config).with_observed_manual_executions(replay).unwrap().run().unwrap(); + let seen = seen.borrow(); + for (name, quantity) in [("before", 100), ("auction", 200), ("day", 300), ("scheduled", 500), ("after", 500), ("settlement", 500)] { + assert_eq!(seen.iter().find(|entry| entry.0 == name).unwrap().2, quantity, "{name}: {seen:?}"); + } + assert_eq!(seen.iter().filter(|entry| entry.0 == "manual").map(|entry| entry.2).collect::>(), vec![100,200,300,400,500,600]); + assert_eq!(result.manual_executions.len(), 6); + assert!(result.fills.is_empty(), "external manual fills are not silently relabeled as simulated strategy fills"); + assert_eq!(result.equity_curve[0].cash, 3991.); + assert_eq!(result.equity_curve[0].total_equity, 9991.); + assert_eq!(result.equity_curve[0].external_cash_flow, 0.); + assert!((result.metrics.average_daily_turnover - 6000. / 9991.).abs() < 1e-12); + } + + #[test] + fn early_pre_open_schedules_observe_manual_receipts_in_chronological_order() { + struct Probe { seen: Rc>> } + impl Strategy for Probe { + fn name(&self) -> &str { "early-manual-clock" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { vec![ + ScheduleRule::daily("first", ScheduleStage::BeforeTrading).with_time_rule(ScheduleTimeRule::physical_time(8, 50)), + ScheduleRule::daily("second", ScheduleStage::BeforeTrading).with_time_rule(ScheduleTimeRule::physical_time(9, 10)), + ] } + fn on_day(&mut self, _: &StrategyContext<'_>) -> Result { Ok(Default::default()) } + fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, _: &ScheduleRule) -> Result { + self.seen.borrow_mut().push((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); + 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, &[(8,55),(9,20)])).unwrap().run().unwrap(); + assert_eq!(*seen.borrow(), vec![(NaiveTime::from_hms_opt(8,50,0).unwrap(),0),(NaiveTime::from_hms_opt(9,10,0).unwrap(),100)]); + } + + #[test] + fn observed_manual_sale_updates_the_shared_same_day_rebuy_rule() { + struct BuyLater; + impl Strategy for BuyLater { + fn name(&self) -> &str { "manual-sale-followed-by-natural-buy" } + fn requires_minute_callbacks(&self) -> bool { false } + fn schedule_rules(&self) -> Vec { vec![ScheduleRule::daily("buy", ScheduleStage::Minute).with_time_rule(ScheduleTimeRule::physical_time(11, 0))] } + fn on_day(&mut self, _: &StrategyContext<'_>) -> Result { Ok(Default::default()) } + fn on_scheduled(&mut self, _: &StrategyContext<'_>, _: &ScheduleRule) -> Result { + Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.into(), quantity: 100, reason: "natural-buy".into() }], ..Default::default() }) + } + } + let date = d(2026, 6, 1); + let data = clock_probe_data(date, &[(11, 0, 10.)]); + let mut replay = observed_manual_replay(date, &[(9, 0), (10, 0)]); + replay.actions[1].orders[0].side = OrderSide::Sell; + replay.content_sha256 = replay.content_digest().unwrap(); + let mut risk = FidcRiskControlConfig::default(); + risk.static_rules.forbid_same_day_rebuy_after_sell = true; + 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_risk_config(risk); + 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(data, BuyLater, broker, config).with_observed_manual_executions(replay).unwrap().run().unwrap(); + assert_eq!(result.manual_executions.len(), 2); + assert!(result.fills.is_empty()); + assert!(result.order_events.iter().any(|order| order.reason.contains("same_day_rebuy_forbidden"))); + assert!(result.holdings_summary.is_empty()); + assert_eq!(result.equity_curve[0].cash, 9997.); + } + + #[test] + fn empty_manual_source_does_not_retime_natural_strategy_execution() { + let mut empty = observed_manual_replay(d(2025, 1, 3), &[]); + empty.content_sha256 = empty.content_digest().unwrap(); + let mut original = engine_with_matching(MatchingType::NextBarOpen, PriceField::Open, 1); + let mut observed = engine_with_matching(MatchingType::NextBarOpen, PriceField::Open, 1).with_observed_manual_executions(empty).unwrap(); + let original = original.run().unwrap(); + let observed = observed.run().unwrap(); + assert_eq!(serde_json::to_value(&original.order_events).unwrap(), serde_json::to_value(&observed.order_events).unwrap()); + assert_eq!(serde_json::to_value(&original.fills).unwrap(), serde_json::to_value(&observed.fills).unwrap()); + assert_eq!(serde_json::to_value(&original.equity_curve).unwrap(), serde_json::to_value(&observed.equity_curve).unwrap()); + } + #[test] fn join_text_parts_matches_vec_join_contract() { assert_eq!(super::join_text_parts(Vec::::new()), ""); diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index f443f0d..bfdcfe1 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -311,6 +311,7 @@ pub enum ProcessEventKind { OrderUpdateReject, OrderUnsolicitedUpdate, Trade, + ManualExecutionObserved, UniverseUpdated, UniverseSubscribed, UniverseUnsubscribed, @@ -358,6 +359,7 @@ impl ProcessEventKind { Self::OrderUpdateReject => "order_update_reject", Self::OrderUnsolicitedUpdate => "order_unsolicited_update", Self::Trade => "trade", + Self::ManualExecutionObserved => "manual_execution_observed", Self::UniverseUpdated => "universe_updated", Self::UniverseSubscribed => "universe_subscribed", Self::UniverseUnsubscribed => "universe_unsubscribed", @@ -391,6 +393,7 @@ impl ProcessEventKind { | Self::OrderUpdateReject | Self::OrderUnsolicitedUpdate | Self::Trade + | Self::ManualExecutionObserved | Self::UniverseUpdated | Self::UniverseSubscribed | Self::UniverseUnsubscribed diff --git a/crates/fidc-core/src/manual_execution.rs b/crates/fidc-core/src/manual_execution.rs index d6f3a31..adc753f 100644 --- a/crates/fidc-core/src/manual_execution.rs +++ b/crates/fidc-core/src/manual_execution.rs @@ -413,7 +413,7 @@ pub struct AppliedManualFill { /// One replay owns its immutable trace and progress. Advancing is atomic even /// if a later receipt in the same step disagrees with the shadow account. pub struct ManualReplayCursor { - replay: ManualExecutionReplay, + replay: std::sync::Arc, indices: Vec<(usize, usize, usize)>, cursor: usize, clock: Option>, @@ -448,6 +448,10 @@ pub struct ManualReplayApplication { impl ManualReplayCursor { pub fn new(replay: ManualExecutionReplay) -> Result { + Self::from_shared(std::sync::Arc::new(replay)) + } + + pub fn from_shared(replay: std::sync::Arc) -> Result { replay.validate()?; let mut indices = Vec::new(); for (a, action) in replay.actions.iter().enumerate() { @@ -485,6 +489,27 @@ impl ManualReplayCursor { portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool, + ) -> Result, String> { + let end = self.cursor + + self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| { + self.replay.actions[a].orders[o].fills[f].observed_at <= at + }).count(); + self.advance_through(at, end, portfolio, data, has_pending_orders) + } + + /// One receipt at a time lets callbacks observe the intermediate state + /// when multiple fills share a timestamp but have distinct durable sequences. + pub fn advance_next( + &mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool, + ) -> Result, String> { + let Some(at) = self.next_observation_at() else { return Ok(None); }; + let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?; + Ok(applications.pop()) + } + + fn advance_through( + &mut self, at: DateTime, end: usize, portfolio: &mut PortfolioState, + data: &DataSet, has_pending_orders: bool, ) -> Result, String> { if at > self.replay.observation_cutoff { return Err("manual observation clock exceeds the frozen evidence cutoff".into()); @@ -492,13 +517,6 @@ impl ManualReplayCursor { if self.clock.is_some_and(|clock| at < clock) { return Err("manual observation clock moved backwards".into()); } - let end = self.cursor - + self.indices[self.cursor..] - .iter() - .take_while(|&&(a, o, f)| { - self.replay.actions[a].orders[o].fills[f].observed_at <= at - }) - .count(); if end == self.cursor { self.clock = Some(at); return Ok(vec![]); diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index 47990e4..4f8f5f3 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -93,6 +93,15 @@ pub fn compute_backtest_metrics( account_events: &[AccountEvent], initial_cash: f64, risk_free_contract: Option<&RiskFreeRateContract>, +) -> Result { + compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract) +} + +pub fn compute_backtest_metrics_with_manual( + equity_curve: &[DailyEquityPoint], fills: &[FillEvent], + manual_executions: &[crate::manual_execution::ManualReplayApplication], + daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64, + risk_free_contract: Option<&RiskFreeRateContract>, ) -> Result { let Some(first_point) = equity_curve.first() else { return Ok(BacktestMetrics { @@ -229,12 +238,20 @@ pub fn compute_backtest_metrics( ); let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); - let turnover_by_date = fills + let mut turnover_by_date = fills .iter() .fold(BTreeMap::::new(), |mut acc, fill| { *acc.entry(fill.date).or_default() += fill.gross_amount.abs(); acc }); + for execution in manual_executions { + use rust_decimal::prelude::ToPrimitive; + let gross = execution.ledger_gross_amount.parse::() + .ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.) + .ok_or("manual turnover requires its validated ledger gross amount")?; + let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive(); + *turnover_by_date.entry(date).or_default() += gross; + } let equity_by_date = equity_curve .iter() .map(|point| (point.date, point.total_equity)) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index f7315f5..b1a2cb7 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -12396,6 +12396,12 @@ impl PlatformExprStrategy { } impl Strategy for PlatformExprStrategy { + fn on_observed_manual_execution(&mut self, execution: &crate::manual_execution::ManualReplayApplication) -> Result<(), BacktestError> { + let date = execution.executed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive(); + let history = match execution.side { OrderSide::Buy => &mut self.protection_last_buys, OrderSide::Sell => &mut self.protection_last_sells }; + history.entry(execution.symbol.clone()).and_modify(|previous| *previous = (*previous).max(date)).or_insert(date); + Ok(()) + } fn name(&self) -> &str { self.config.strategy_name.as_str() } diff --git a/crates/fidc-core/src/scheduler.rs b/crates/fidc-core/src/scheduler.rs index d22c8d6..7396433 100644 --- a/crates/fidc-core/src/scheduler.rs +++ b/crates/fidc-core/src/scheduler.rs @@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> { pub fn default_stage_time(stage: ScheduleStage) -> Option { match stage { ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")), - ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), + ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")), ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::Minute => None, ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 2a91976..5c6285c 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -40,6 +40,12 @@ pub trait Strategy { ) -> Result<(), BacktestError> { Ok(()) } + /// External, already executed manual activity. It is not a new strategy + /// order and must not be run through order generation or transaction costs. + fn on_observed_manual_execution( + &mut self, + _execution: &crate::manual_execution::ManualReplayApplication, + ) -> Result<(), BacktestError> { Ok(()) } fn schedule_rules(&self) -> Vec { Vec::new() } diff --git a/docs/manual-execution-clock-20260914.md b/docs/manual-execution-clock-20260914.md new file mode 100644 index 0000000..7f58367 --- /dev/null +++ b/docs/manual-execution-clock-20260914.md @@ -0,0 +1,33 @@ +# 手工观察主时钟接入候选 + +2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。 + +## 本阶段已实现 + +- BacktestEngine可显式绑定严格v2手工观察输入。原始回报时刻驱动账本;同一时刻按真实观察序号逐笔原子应用,回调能看到100、200而不是第一笔就看到两笔总量。 +- 默认盘前、开盘、盘中、收盘/结算及当日晚到回报纳入处理;跨会话观察先于下一会话公司行为,不生成行情行;结束后仍未覆盖的观察明确失败,不截断成成功。 +- 手工成交写独立来源及应用明细,不冒充模拟策略FillEvent。账户变化不计作出入金;手续费只扣一次,最终费用来源/时间仍单独保留。 +- 股数及现金改变后通知策略,真实买卖日期更新持有保护和卖后禁买证据;券商模拟器的当日卖后禁买规则同样接收手工卖出,不把手工绕过自动条件理解为抹掉真实成交历史。 +- 分钟时钟不必依赖策略订阅或同一时刻市场报价,手工价格也不会伪造为市场行情。已有挂单/待执行目标冲突仍明确拒绝,不替用户撤单或重建目标。 +- 流式数量、原始观察明细及换手率纳入手工应用;纯无成交的来源不会改变自然策略时钟。 + +## 已复现并修正的问题 + +旧默认OpenAuction回调在09:31,接着却可能执行09:30日内步骤。手工09:27观察会由此先进入09:31再倒退到09:30。已把默认开盘阶段放在09:25,并保留显式调度时间。 + +盘前08:50/09:10规则原来在同一状态上顺序计算,不能正确看到夹在两者之间的08:55回报。现按实际到期时间交错处理回报、调度、资金等指令和撤改控制;盘前阶段若跨越开盘阶段,明确报告冲突,不把晚时点状态带回早时点。 + +## 当前验证 + +Core864通过(9项原ignore不计通过)。新增四个引擎用例验证默认阶段、同刻两次回报、08:50/09:10盘前交错、手工卖后自然买入风控,以及空手工来源的自然委托/成交/权益等价。默认阶段样例最终600股、现金3991、权益9991、出入金0;不是实际券商行情验收。 + +结果协议和API/Runner的候选接入见fidc-backtest-service/docs/manual-execution-run-contract-20260914.md。当前正常记录/费用原始精度不改;所有新代码尚未发布,影子调用仍没有解除四类纯比例拒绝门禁。 + +## 必须继续 + +1. 显式Opening/AfterTrading/Settlement调度的完整跨阶段交错、会话外未提交意图、待执行目标冲突与公司行为/跨日组合仍需完成,不把本轮默认阶段样例当全日历证明。 +2. 完成影子调度调用、所需历史证券范围、来源权限/归属、实际HTTP和Linux验收;不以独立输入/结果单测冒充端到端。 +3. 结果委托/成交分页接口与统一UI仍须合并展示外部手工来源,保留未知组件和完整原始ID,不把仅落库视为呈现已完成。 +4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。 + +本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6,Source d5/PID1700096冻结与研究暂停不改。 diff --git a/docs/manual-replay-foundation-20260914.md b/docs/manual-replay-foundation-20260914.md index 2eaef9e..eee49f3 100644 --- a/docs/manual-replay-foundation-20260914.md +++ b/docs/manual-replay-foundation-20260914.md @@ -4,6 +4,8 @@ ## v2读取合同补充 +默认主时钟、盘前交错与独立结果来源已开始配套接入,当前阶段/真实缺口改由docs/manual-execution-clock-20260914.md维护。本基础模块通过不等于完整阶段日历或生产影子已启用。 + 总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。 分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。