|
|
|
@@ -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()), "");
|
|
|
|
|