将手工观察接入执行时钟并保留来源与账本语义

This commit is contained in:
boris
2026-09-14 11:41:15 +08:00
parent 93de28d369
commit 7f0c6a008a
10 changed files with 435 additions and 95 deletions
+7
View File
@@ -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,
+333 -85
View File
@@ -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<FidcRiskDecisionAudit>,
pub order_events: Vec<OrderEvent>,
pub fills: Vec<FillEvent>,
pub manual_executions: Vec<crate::manual_execution::ManualReplayApplication>,
pub manual_execution_source: Option<std::sync::Arc<crate::manual_execution::ManualExecutionReplay>>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub process_events: Vec<ProcessEvent>,
@@ -437,6 +439,8 @@ pub struct BacktestDayProgress {
pub diagnostics: String,
pub orders: Vec<OrderEvent>,
pub fills: Vec<FillEvent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub manual_executions: Vec<crate::manual_execution::ManualReplayApplication>,
pub holdings: Vec<HoldingSummary>,
pub process_events: Vec<ProcessEvent>,
}
@@ -477,6 +481,7 @@ pub struct BacktestEngine<S, C, R> {
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
execution_lifecycle_reported: BTreeSet<(String, String)>,
risk_free_rate_contract: Option<RiskFreeRateContract>,
manual_execution_source: Option<std::sync::Arc<crate::manual_execution::ManualExecutionReplay>>,
}
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
@@ -576,9 +581,16 @@ impl<S, C, R> BacktestEngine<S, C, R> {
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<Self, BacktestError> {
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<crate::manual_execution::ManualReplayCursor>,
through: chrono::NaiveDateTime,
portfolio: &mut PortfolioState,
result: &mut BacktestResult,
process_events: &mut Vec<ProcessEvent>,
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<crate::manual_execution::ManualReplayCursor>, result: &mut BacktestResult,
events: &mut Vec<ProcessEvent>, directive_report: &mut BrokerExecutionReport,
) -> Result<(StrategyDecision, NaiveTime), BacktestError> {
let active_rules = rules.iter().filter(|rule| scheduler.is_due_on(decision_date, rule)).cloned().collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<S: Strategy>(
default_time_override: Option<NaiveTime>,
physical_time: Option<NaiveTime>,
) -> Result<crate::strategy::StrategyDecision, 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);
}
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<S: Strategy>(
Ok(combined)
}
fn scheduled_stage_times(stage: ScheduleStage, rules: &[ScheduleRule], default_time_override: Option<NaiveTime>) -> Result<BTreeSet<Option<NaiveTime>>, 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<chrono::NaiveDateTime>,
@@ -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::<chrono::Utc>::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<RefCell<Vec<(String, NaiveTime, u32)>>> }
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<ScheduleRule> { 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<StrategyDecision, crate::BacktestError> { self.record("auction", ctx); Ok(Default::default()) }
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, crate::BacktestError> { self.record("day", ctx); Ok(Default::default()) }
fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> { 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<_>>(), 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<RefCell<Vec<(NaiveTime, u32)>>> }
impl Strategy for Probe {
fn name(&self) -> &str { "early-manual-clock" }
fn requires_minute_callbacks(&self) -> bool { false }
fn schedule_rules(&self) -> Vec<ScheduleRule> { 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<StrategyDecision, crate::BacktestError> { Ok(Default::default()) }
fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
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<ScheduleRule> { vec![ScheduleRule::daily("buy", ScheduleStage::Minute).with_time_rule(ScheduleTimeRule::physical_time(11, 0))] }
fn on_day(&mut self, _: &StrategyContext<'_>) -> Result<StrategyDecision, crate::BacktestError> { Ok(Default::default()) }
fn on_scheduled(&mut self, _: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
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::<String>::new()), "");
+3
View File
@@ -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
+26 -8
View File
@@ -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<ManualExecutionReplay>,
indices: Vec<(usize, usize, usize)>,
cursor: usize,
clock: Option<DateTime<Utc>>,
@@ -448,6 +448,10 @@ pub struct ManualReplayApplication {
impl ManualReplayCursor {
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
Self::from_shared(std::sync::Arc::new(replay))
}
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
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<Vec<ManualReplayApplication>, 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<Option<ManualReplayApplication>, 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<Utc>, end: usize, portfolio: &mut PortfolioState,
data: &DataSet, has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, 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![]);
+18 -1
View File
@@ -93,6 +93,15 @@ pub fn compute_backtest_metrics(
account_events: &[AccountEvent],
initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
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<BacktestMetrics, String> {
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::<NaiveDate, f64>::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::<rust_decimal::Decimal>()
.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))
@@ -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()
}
+1 -1
View File
@@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> {
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
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")),
+6
View File
@@ -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<ScheduleRule> {
Vec::new()
}
+33
View File
@@ -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.6Source d5/PID1700096冻结与研究暂停不改。
@@ -4,6 +4,8 @@
## v2读取合同补充
默认主时钟、盘前交错与独立结果来源已开始配套接入,当前阶段/真实缺口改由docs/manual-execution-clock-20260914.md维护。本基础模块通过不等于完整阶段日历或生产影子已启用。
总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。
分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。