|
|
|
@@ -170,6 +170,10 @@ pub enum BacktestTerminalAssetClass {
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct BacktestTerminalAudit {
|
|
|
|
|
#[serde(default, skip_serializing_if = "is_zero_count")]
|
|
|
|
|
pub deferred_strategy_decision_count: usize,
|
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
pub deferred_strategy_decisions: Vec<serde_json::Value>,
|
|
|
|
|
#[serde(default, skip_serializing_if = "is_zero_count")]
|
|
|
|
|
pub deferred_etf_target_count: usize,
|
|
|
|
|
pub status: BacktestTerminalStatus,
|
|
|
|
@@ -188,6 +192,8 @@ pub struct BacktestTerminalAudit {
|
|
|
|
|
impl Default for BacktestTerminalAudit {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
deferred_strategy_decision_count: 0,
|
|
|
|
|
deferred_strategy_decisions: Vec::new(),
|
|
|
|
|
deferred_etf_target_count: 0,
|
|
|
|
|
status: BacktestTerminalStatus::Clean,
|
|
|
|
|
last_execution_date: None,
|
|
|
|
@@ -482,6 +488,16 @@ pub struct BacktestEngine<S, C, R> {
|
|
|
|
|
execution_lifecycle_reported: BTreeSet<(String, String)>,
|
|
|
|
|
risk_free_rate_contract: Option<RiskFreeRateContract>,
|
|
|
|
|
manual_execution_source: Option<std::sync::Arc<crate::manual_execution::ManualExecutionReplay>>,
|
|
|
|
|
deferred_session_decisions: Vec<DeferredSessionDecision>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
struct DeferredSessionDecision {
|
|
|
|
|
created_at: chrono::NaiveDateTime,
|
|
|
|
|
decision_date: NaiveDate,
|
|
|
|
|
decision_index: usize,
|
|
|
|
|
decision_equity: f64,
|
|
|
|
|
decision: StrategyDecision,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
|
|
|
|
@@ -550,6 +566,31 @@ pub fn backtest_execution_dates(
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn backtest_execution_schedule_with_rules(
|
|
|
|
|
data: &DataSet, start_date: Option<NaiveDate>, end_date: Option<NaiveDate>,
|
|
|
|
|
decision_lag_trading_days: usize, rules: &[ScheduleRule],
|
|
|
|
|
) -> Vec<(NaiveDate, Option<(usize, NaiveDate)>)> {
|
|
|
|
|
let schedule = backtest_execution_schedule(data, start_date, end_date, decision_lag_trading_days);
|
|
|
|
|
if !rules.iter().any(|rule| matches!(rule.stage, ScheduleStage::AfterTrading | ScheduleStage::Settlement)) {
|
|
|
|
|
return schedule;
|
|
|
|
|
}
|
|
|
|
|
let planned = schedule.into_iter().collect::<BTreeMap<_, _>>();
|
|
|
|
|
data.calendar().iter()
|
|
|
|
|
.filter(|date| start_date.is_none_or(|start| *date >= start))
|
|
|
|
|
.filter(|date| end_date.is_none_or(|end| *date <= end))
|
|
|
|
|
.map(|date| (date, planned.get(&date).copied().flatten())).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Use the declared lifecycle rules when aligning outside data (for example,
|
|
|
|
|
/// risk-free observations) with the engine's complete equity calendar.
|
|
|
|
|
pub fn backtest_execution_dates_with_rules(
|
|
|
|
|
data: &DataSet, start_date: Option<NaiveDate>, end_date: Option<NaiveDate>,
|
|
|
|
|
decision_lag_trading_days: usize, rules: &[ScheduleRule],
|
|
|
|
|
) -> Vec<NaiveDate> {
|
|
|
|
|
backtest_execution_schedule_with_rules(data, start_date, end_date, decision_lag_trading_days, rules)
|
|
|
|
|
.into_iter().map(|(date, _)| date).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<S, C, R> BacktestEngine<S, C, R> {
|
|
|
|
|
pub fn new(
|
|
|
|
|
data: DataSet,
|
|
|
|
@@ -582,6 +623,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|
|
|
|
execution_lifecycle_reported: BTreeSet::new(),
|
|
|
|
|
risk_free_rate_contract: None,
|
|
|
|
|
manual_execution_source: None,
|
|
|
|
|
deferred_session_decisions: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -776,24 +818,25 @@ where
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_before_trading_schedules(
|
|
|
|
|
fn collect_lifecycle_schedules(
|
|
|
|
|
&mut self, scheduler: &Scheduler<'_>, execution_date: NaiveDate, decision_date: NaiveDate,
|
|
|
|
|
decision_index: usize, rules: &[ScheduleRule], portfolio: &mut PortfolioState,
|
|
|
|
|
decision_index: usize, stage: ScheduleStage, rules: &[ScheduleRule],
|
|
|
|
|
phase_start: NaiveTime, phase_limit: Option<NaiveTime>, default_override: Option<NaiveTime>, 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 times = scheduled_stage_times(stage, &active_rules, default_override)?;
|
|
|
|
|
let phase_end = times.iter().flatten().copied().chain(std::iter::once(phase_start)).max().unwrap();
|
|
|
|
|
if times.iter().flatten().any(|time| *time < phase_start) || phase_limit.is_some_and(|limit| phase_end > limit) {
|
|
|
|
|
return Err(BacktestError::Execution(format!("{} schedule overlaps another lifecycle phase; use a trading-session stage for in-session callbacks", stage_label(stage))));
|
|
|
|
|
}
|
|
|
|
|
let mut combined = StrategyDecision::default();
|
|
|
|
|
for time in times.into_iter().flatten() {
|
|
|
|
|
self.observe_manual_until(cursor, execution_date.and_time(time), portfolio, result, events, Some((decision_date, decision_index)))?;
|
|
|
|
|
let orders = self.open_order_views();
|
|
|
|
|
let mut decision = collect_scheduled_decisions(&mut self.strategy, scheduler, execution_date,
|
|
|
|
|
ScheduleStage::BeforeTrading, &active_rules, decision_date, decision_index, &self.data, portfolio,
|
|
|
|
|
stage, &active_rules, decision_date, decision_index, &self.data, portfolio,
|
|
|
|
|
self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions,
|
|
|
|
|
events, &mut self.process_event_bus, Some(time), &result.order_events, &result.fills, Some(time))?;
|
|
|
|
|
self.apply_strategy_directives(execution_date, decision_date, decision_index, portfolio, events,
|
|
|
|
@@ -811,11 +854,104 @@ where
|
|
|
|
|
events, &mut report.process_events, CallbackObservation::from_result(result, Some(execution_date.and_time(time))))?;
|
|
|
|
|
merge_broker_report(directive_report, report);
|
|
|
|
|
}
|
|
|
|
|
if matches!(stage, ScheduleStage::AfterTrading | ScheduleStage::Settlement)
|
|
|
|
|
&& (decision.rebalance || !decision.order_intents.is_empty() || !decision.exit_symbols.is_empty()) {
|
|
|
|
|
let mut pending = decision.clone();
|
|
|
|
|
pending.notes.clear(); pending.diagnostics.clear();
|
|
|
|
|
let deferred = DeferredSessionDecision {
|
|
|
|
|
created_at: execution_date.and_time(time), decision_date, decision_index,
|
|
|
|
|
decision_equity: portfolio.total_equity(), decision: pending,
|
|
|
|
|
};
|
|
|
|
|
crate::finite_serialization::validate(&deferred)
|
|
|
|
|
.map_err(|error| BacktestError::Execution(error.to_string()))?;
|
|
|
|
|
if deferred.decision.is_portfolio_target_only() {
|
|
|
|
|
let previous_count = self.deferred_session_decisions.len();
|
|
|
|
|
self.deferred_session_decisions.retain(|previous| !previous.decision.is_portfolio_target_only());
|
|
|
|
|
if self.deferred_session_decisions.len() < previous_count {
|
|
|
|
|
decision.diagnostics.push("unsubmitted_portfolio_target_superseded".into());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.deferred_session_decisions.push(deferred);
|
|
|
|
|
decision.rebalance = false; decision.target_weights.clear(); decision.order_intents.clear(); decision.exit_symbols.clear();
|
|
|
|
|
decision.diagnostics.push(format!("strategy_intent_deferred_to_next_session origin_stage={} created_at={}", stage_label(stage), execution_date.and_time(time)));
|
|
|
|
|
}
|
|
|
|
|
combined.merge_from(decision);
|
|
|
|
|
}
|
|
|
|
|
Ok((combined, phase_end))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Execute an existing intent even when this market session has no new
|
|
|
|
|
/// factor/selection snapshot. Its signal context is never fabricated.
|
|
|
|
|
fn execute_deferred_session_without_signal(
|
|
|
|
|
&mut self, date: NaiveDate, portfolio: &mut PortfolioState,
|
|
|
|
|
cursor: &mut Option<crate::manual_execution::ManualReplayCursor>,
|
|
|
|
|
result: &mut BacktestResult, events: &mut Vec<ProcessEvent>,
|
|
|
|
|
) -> Result<BrokerExecutionReport, BacktestError> {
|
|
|
|
|
let mut report = BrokerExecutionReport::default();
|
|
|
|
|
let pending = std::mem::take(&mut self.deferred_session_decisions);
|
|
|
|
|
if pending.is_empty() { return Ok(report); }
|
|
|
|
|
let latest_target = pending.iter().rposition(|batch| batch.decision.is_portfolio_target_only());
|
|
|
|
|
let pending = pending.into_iter().enumerate().filter_map(|(index, batch)| {
|
|
|
|
|
if batch.decision.is_portfolio_target_only() && Some(index) != latest_target {
|
|
|
|
|
result.risk_decisions.extend(batch.decision.risk_decisions);
|
|
|
|
|
report.diagnostics.push("unsubmitted_portfolio_target_superseded".into());
|
|
|
|
|
None
|
|
|
|
|
} else { Some(batch) }
|
|
|
|
|
}).collect::<Vec<_>>();
|
|
|
|
|
let clock = self.broker.intraday_execution_start_time().unwrap_or_else(|| match self.broker.matching_type() {
|
|
|
|
|
MatchingType::CurrentBarClose if self.broker.execution_price_field() == PriceField::Close => NaiveTime::from_hms_opt(15,0,0).unwrap(),
|
|
|
|
|
_ => NaiveTime::from_hms_opt(9,30,0).unwrap(),
|
|
|
|
|
});
|
|
|
|
|
let last = pending.last().expect("a latest deferred intent is retained");
|
|
|
|
|
let callback_origin = (last.decision_date, last.decision_index);
|
|
|
|
|
self.observe_manual_until(cursor, date.and_time(clock), portfolio, result, events, Some(callback_origin))?;
|
|
|
|
|
let mut quote_scope = BTreeSet::new();
|
|
|
|
|
for batch in &pending {
|
|
|
|
|
let orders = self.open_order_views();
|
|
|
|
|
quote_scope.extend(execution_quote_symbols_for_decision(&batch.decision, portfolio, &orders));
|
|
|
|
|
self.ensure_execution_quotes_for_decision(date, batch.created_at.date(), portfolio, &orders, &batch.decision, None, None)?;
|
|
|
|
|
let mut part = self.broker.execute_coarse_at_clock(date, batch.decision_date, batch.created_at.date(), Some(batch.decision_equity),
|
|
|
|
|
portfolio, &self.data, &batch.decision, Some(clock))?;
|
|
|
|
|
annotate_broker_report_dates(&mut part, batch.decision_date, batch.created_at.date(), date);
|
|
|
|
|
result.risk_decisions.extend(risk_decisions_from_order_events(&part.order_events));
|
|
|
|
|
Self::record_execution_history(result, &mut part, batch.decision_date, date);
|
|
|
|
|
let orders = self.open_order_views();
|
|
|
|
|
publish_process_events(&mut self.strategy, &mut self.process_event_bus, date, batch.decision_date, batch.decision_index,
|
|
|
|
|
&self.data, portfolio, self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions,
|
|
|
|
|
events, &mut part.process_events, CallbackObservation::from_result(result, Some(date.and_time(clock))))?;
|
|
|
|
|
merge_broker_report(&mut report, part);
|
|
|
|
|
}
|
|
|
|
|
let quote_data = self.data.clone();
|
|
|
|
|
let mut clocks = quote_data.execution_quotes_iter_on_date_for_symbols(date, Some("e_scope))
|
|
|
|
|
.map(|quote| quote.timestamp).filter(|at| at.time() > clock)
|
|
|
|
|
.peekable();
|
|
|
|
|
let end = default_stage_time(ScheduleStage::AfterTrading).unwrap().max(clock);
|
|
|
|
|
let mut observed_through = date.and_time(clock);
|
|
|
|
|
loop {
|
|
|
|
|
let expiry = self.broker.next_day_order_expiry(date).map(|time| date.and_time(time))
|
|
|
|
|
.filter(|at| *at > observed_through);
|
|
|
|
|
let natural = clocks.peek().copied().into_iter().chain(expiry).min();
|
|
|
|
|
let manual = cursor.as_ref().and_then(|value| value.next_observation_at())
|
|
|
|
|
.map(|at| at.with_timezone(&chrono::FixedOffset::east_opt(8*3600).unwrap()).naive_local())
|
|
|
|
|
.filter(|at| at.date() == date && at.time() <= natural.map_or(end, |at| at.time().max(end)));
|
|
|
|
|
let Some(at) = natural.into_iter().chain(manual).min() else { break; };
|
|
|
|
|
observed_through = at;
|
|
|
|
|
self.observe_manual_until(cursor, at, portfolio, result, events, Some(callback_origin))?;
|
|
|
|
|
while clocks.peek() == Some(&at) { clocks.next(); }
|
|
|
|
|
let mut part = self.broker.execute_coarse_at_clock(date, callback_origin.0, date, None,
|
|
|
|
|
portfolio, &self.data, &StrategyDecision::default(), Some(at.time()))?;
|
|
|
|
|
result.risk_decisions.extend(risk_decisions_from_order_events(&part.order_events));
|
|
|
|
|
Self::record_execution_history(result, &mut part, callback_origin.0, date);
|
|
|
|
|
let orders = self.open_order_views();
|
|
|
|
|
publish_process_events(&mut self.strategy, &mut self.process_event_bus, date, callback_origin.0, callback_origin.1,
|
|
|
|
|
&self.data, portfolio, self.futures_account.as_ref(), &orders, self.dynamic_universe.as_ref(), &self.subscriptions,
|
|
|
|
|
events, &mut part.process_events, CallbackObservation::from_result(result, Some(at)))?;
|
|
|
|
|
merge_broker_report(&mut report, part);
|
|
|
|
|
}
|
|
|
|
|
report.diagnostics.push("deferred_strategy_session_no_new_signal original_signal_preserved=true".into());
|
|
|
|
|
Ok(report)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_execution_quotes_for_decision(
|
|
|
|
|
&mut self,
|
|
|
|
|
execution_date: NaiveDate,
|
|
|
|
@@ -1468,7 +1604,7 @@ where
|
|
|
|
|
&self,
|
|
|
|
|
portfolio: &PortfolioState,
|
|
|
|
|
last_execution_date: Option<NaiveDate>,
|
|
|
|
|
) -> BacktestTerminalAudit {
|
|
|
|
|
) -> Result<BacktestTerminalAudit, BacktestError> {
|
|
|
|
|
const OPEN_ORDER_SAMPLE_LIMIT: usize = 20;
|
|
|
|
|
|
|
|
|
|
let stock_open_orders = self.broker.open_order_views();
|
|
|
|
@@ -1524,13 +1660,17 @@ where
|
|
|
|
|
&& pending_cash_flow_count == 0
|
|
|
|
|
&& cash_receivable_count == 0
|
|
|
|
|
&& self.broker.pending_etf_target_count() == 0
|
|
|
|
|
&& self.deferred_session_decisions.is_empty()
|
|
|
|
|
{
|
|
|
|
|
BacktestTerminalStatus::Clean
|
|
|
|
|
} else {
|
|
|
|
|
BacktestTerminalStatus::CompletedWithPendingState
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
BacktestTerminalAudit {
|
|
|
|
|
Ok(BacktestTerminalAudit {
|
|
|
|
|
deferred_strategy_decision_count: self.deferred_session_decisions.len(),
|
|
|
|
|
deferred_strategy_decisions: self.deferred_session_decisions.iter().map(serde_json::to_value)
|
|
|
|
|
.collect::<Result<Vec<_>, _>>().map_err(|error| BacktestError::Execution(format!("cannot retain deferred strategy intent: {error}")))?,
|
|
|
|
|
deferred_etf_target_count: self.broker.pending_etf_target_count(),
|
|
|
|
|
status,
|
|
|
|
|
last_execution_date,
|
|
|
|
@@ -1543,7 +1683,7 @@ where
|
|
|
|
|
earliest_deferred_cash_date,
|
|
|
|
|
omitted_open_order_count: open_order_count.saturating_sub(open_order_samples.len()),
|
|
|
|
|
open_order_samples,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn has_open_orders(&self) -> bool {
|
|
|
|
@@ -2212,7 +2352,7 @@ where
|
|
|
|
|
directive_report: &mut BrokerExecutionReport,
|
|
|
|
|
report: &mut BrokerExecutionReport,
|
|
|
|
|
clock: Option<NaiveTime>,
|
|
|
|
|
earlier_target: Option<StrategyDecision>,
|
|
|
|
|
earlier_target: Option<DeferredSessionDecision>,
|
|
|
|
|
) -> Result<StrategyDecision, BacktestError> {
|
|
|
|
|
let (execution_date, decision_date, decision_index, decision_total_equity) = timing;
|
|
|
|
|
let logical_time = |stage| {
|
|
|
|
@@ -2417,21 +2557,23 @@ where
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
let mut superseded_audits = Vec::new();
|
|
|
|
|
let mut execution_origin = (decision_date, decision_date, decision_total_equity);
|
|
|
|
|
if let Some(mut earlier) = earlier_target {
|
|
|
|
|
if decision.rebalance || !decision.order_intents.is_empty() || !decision.exit_symbols.is_empty() {
|
|
|
|
|
decision.notes.splice(0..0, earlier.notes);
|
|
|
|
|
decision.diagnostics.splice(0..0, earlier.diagnostics);
|
|
|
|
|
decision.notes.splice(0..0, earlier.decision.notes);
|
|
|
|
|
decision.diagnostics.splice(0..0, earlier.decision.diagnostics);
|
|
|
|
|
decision.diagnostics.push("unsubmitted_pre_market_target_superseded".into());
|
|
|
|
|
superseded_audits.append(&mut earlier.risk_decisions);
|
|
|
|
|
superseded_audits.append(&mut earlier.decision.risk_decisions);
|
|
|
|
|
} else {
|
|
|
|
|
earlier.merge_from(decision);
|
|
|
|
|
decision = earlier;
|
|
|
|
|
earlier.decision.merge_from(decision);
|
|
|
|
|
execution_origin = (earlier.decision_date, earlier.created_at.date(), Some(earlier.decision_equity));
|
|
|
|
|
decision = earlier.decision;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let pre_intraday_execution_orders = self.open_order_views();
|
|
|
|
|
self.ensure_execution_quotes_for_decision(
|
|
|
|
|
execution_date,
|
|
|
|
|
decision_date,
|
|
|
|
|
execution_origin.1,
|
|
|
|
|
portfolio,
|
|
|
|
|
&pre_intraday_execution_orders,
|
|
|
|
|
&decision,
|
|
|
|
@@ -2440,14 +2582,15 @@ where
|
|
|
|
|
)?;
|
|
|
|
|
let mut intraday_report = self.broker.execute_coarse_at_clock(
|
|
|
|
|
execution_date,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_total_equity,
|
|
|
|
|
execution_origin.0,
|
|
|
|
|
execution_origin.1,
|
|
|
|
|
execution_origin.2,
|
|
|
|
|
portfolio,
|
|
|
|
|
&self.data,
|
|
|
|
|
&decision,
|
|
|
|
|
clock,
|
|
|
|
|
)?;
|
|
|
|
|
annotate_broker_report_dates(&mut intraday_report, execution_origin.0, execution_origin.1, execution_date);
|
|
|
|
|
Self::record_execution_history(result, directive_report, decision_date, execution_date);
|
|
|
|
|
Self::record_execution_history(result, &mut intraday_report, decision_date, execution_date);
|
|
|
|
|
let post_intraday_open_orders = self.open_order_views();
|
|
|
|
@@ -2541,11 +2684,12 @@ where
|
|
|
|
|
self.subscriptions = self.strategy.initial_subscriptions();
|
|
|
|
|
let scheduler_calendar = self.data.calendar().clone();
|
|
|
|
|
let scheduler = Scheduler::new(&scheduler_calendar);
|
|
|
|
|
let execution_schedule = backtest_execution_schedule(
|
|
|
|
|
let execution_schedule = backtest_execution_schedule_with_rules(
|
|
|
|
|
&self.data,
|
|
|
|
|
self.config.start_date,
|
|
|
|
|
self.config.end_date,
|
|
|
|
|
self.config.decision_lag_trading_days,
|
|
|
|
|
&self.strategy.schedule_rules(),
|
|
|
|
|
);
|
|
|
|
|
let execution_dates = execution_schedule
|
|
|
|
|
.iter()
|
|
|
|
@@ -2665,7 +2809,10 @@ where
|
|
|
|
|
.and_then(|(_, decision_slot)| *decision_slot);
|
|
|
|
|
let Some((decision_index, decision_date)) = decision_slot else {
|
|
|
|
|
let mut process_events = Vec::new();
|
|
|
|
|
let mut report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
|
|
|
|
|
let mut report = self.execute_deferred_session_without_signal(execution_date, &mut portfolio,
|
|
|
|
|
&mut manual_cursor, &mut result, &mut process_events)?;
|
|
|
|
|
let etf_report = self.broker.execute_deferred_etf_targets(execution_date, &mut portfolio, &self.data)?;
|
|
|
|
|
merge_broker_report(&mut report, etf_report);
|
|
|
|
|
self.observe_manual_until(&mut manual_cursor, execution_date.and_hms_nano_opt(23, 59, 59, 999_999_999).unwrap(),
|
|
|
|
|
&mut portfolio, &mut result, &mut process_events, None)?;
|
|
|
|
|
portfolio.update_prices_with_options(
|
|
|
|
@@ -2780,6 +2927,9 @@ where
|
|
|
|
|
let active_before_rules = schedule_rules.iter().filter(|rule| rule.stage == ScheduleStage::BeforeTrading && scheduler.is_due_on(decision_date, rule)).cloned().collect::<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();
|
|
|
|
|
let active_opening_rules = schedule_rules.iter().filter(|rule| rule.stage == ScheduleStage::OpenAuction && scheduler.is_due_on(decision_date, rule)).cloned().collect::<Vec<_>>();
|
|
|
|
|
let opening_start_time = scheduled_stage_times(ScheduleStage::OpenAuction, &active_opening_rules, None)?.into_iter().flatten()
|
|
|
|
|
.chain(default_stage_time(ScheduleStage::OpenAuction)).min().unwrap();
|
|
|
|
|
self.observe_manual_until(&mut manual_cursor,
|
|
|
|
|
execution_date.and_time(before_start_time),
|
|
|
|
|
&mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?;
|
|
|
|
@@ -2857,8 +3007,9 @@ where
|
|
|
|
|
"before_trading",
|
|
|
|
|
CallbackObservation::from_result(&result, Some(execution_date.and_time(before_start_time))),
|
|
|
|
|
)?;
|
|
|
|
|
let (before_trading_decision, before_end_time) = self.collect_before_trading_schedules(
|
|
|
|
|
&scheduler, execution_date, decision_date, decision_index, &coarse_schedule_rules,
|
|
|
|
|
let (before_trading_decision, before_end_time) = self.collect_lifecycle_schedules(
|
|
|
|
|
&scheduler, execution_date, decision_date, decision_index, ScheduleStage::BeforeTrading, &coarse_schedule_rules,
|
|
|
|
|
before_start_time, Some(opening_start_time), None,
|
|
|
|
|
&mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report,
|
|
|
|
|
)?;
|
|
|
|
|
let pre_open_orders = self.open_order_views();
|
|
|
|
@@ -2881,7 +3032,7 @@ where
|
|
|
|
|
CallbackObservation::from_result(&result, Some(execution_date.and_time(before_end_time))),
|
|
|
|
|
)?;
|
|
|
|
|
self.observe_manual_until(&mut manual_cursor,
|
|
|
|
|
execution_date.and_time(default_stage_time(ScheduleStage::OpenAuction).expect("auction clock")),
|
|
|
|
|
execution_date.and_time(opening_start_time),
|
|
|
|
|
&mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?;
|
|
|
|
|
publish_phase_event(
|
|
|
|
|
&mut self.strategy,
|
|
|
|
@@ -2899,28 +3050,12 @@ where
|
|
|
|
|
execution_date,
|
|
|
|
|
ProcessEventKind::PreOpenAuction,
|
|
|
|
|
"open_auction:pre",
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))),
|
|
|
|
|
CallbackObservation::from_result(&result, Some(execution_date.and_time(opening_start_time))),
|
|
|
|
|
)?;
|
|
|
|
|
let mut auction_decision = collect_scheduled_decisions_for_stage(
|
|
|
|
|
&mut self.strategy,
|
|
|
|
|
&scheduler,
|
|
|
|
|
execution_date,
|
|
|
|
|
ScheduleStage::OpenAuction,
|
|
|
|
|
&coarse_schedule_rules,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_index,
|
|
|
|
|
&self.data,
|
|
|
|
|
&portfolio,
|
|
|
|
|
self.futures_account.as_ref(),
|
|
|
|
|
&pre_open_orders,
|
|
|
|
|
self.dynamic_universe.as_ref(),
|
|
|
|
|
&self.subscriptions,
|
|
|
|
|
&mut process_events,
|
|
|
|
|
&mut self.process_event_bus,
|
|
|
|
|
result.order_events.as_slice(),
|
|
|
|
|
result.fills.as_slice(),
|
|
|
|
|
None,
|
|
|
|
|
default_stage_time(ScheduleStage::OpenAuction),
|
|
|
|
|
let (mut auction_decision, opening_end_time) = self.collect_lifecycle_schedules(
|
|
|
|
|
&scheduler, execution_date, decision_date, decision_index, ScheduleStage::OpenAuction, &coarse_schedule_rules,
|
|
|
|
|
opening_start_time.max(before_end_time), self.broker.intraday_execution_start_time(), None,
|
|
|
|
|
&mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report,
|
|
|
|
|
)?;
|
|
|
|
|
auction_decision.merge_from(self.strategy.open_auction(&StrategyContext {
|
|
|
|
|
execution_date,
|
|
|
|
@@ -2936,7 +3071,7 @@ where
|
|
|
|
|
active_process_event: None,
|
|
|
|
|
active_datetime: stage_datetime(
|
|
|
|
|
decision_date,
|
|
|
|
|
default_stage_time(ScheduleStage::OpenAuction),
|
|
|
|
|
Some(opening_end_time),
|
|
|
|
|
),
|
|
|
|
|
order_events: result.order_events.as_slice(),
|
|
|
|
|
fills: result.fills.as_slice(),
|
|
|
|
@@ -2957,7 +3092,7 @@ where
|
|
|
|
|
execution_date,
|
|
|
|
|
ProcessEventKind::OpenAuction,
|
|
|
|
|
"open_auction",
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))),
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))),
|
|
|
|
|
)?;
|
|
|
|
|
self.apply_strategy_directives(
|
|
|
|
|
execution_date,
|
|
|
|
@@ -2968,27 +3103,33 @@ where
|
|
|
|
|
&mut auction_decision,
|
|
|
|
|
&mut directive_report,
|
|
|
|
|
&mut result,
|
|
|
|
|
stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction)),
|
|
|
|
|
stage_datetime(execution_date, Some(opening_end_time)),
|
|
|
|
|
)?;
|
|
|
|
|
let mut pending_portfolio: Option<StrategyDecision> = None;
|
|
|
|
|
let mut pending_portfolio: Option<DeferredSessionDecision> = None;
|
|
|
|
|
let mut pre_day_batches = Vec::new();
|
|
|
|
|
let mut pre_day_telemetry = StrategyDecision::default();
|
|
|
|
|
for mut batch in [before_trading_decision, auction_decision] {
|
|
|
|
|
if batch.is_portfolio_target_only() {
|
|
|
|
|
let mut all_pre_day = std::mem::take(&mut self.deferred_session_decisions);
|
|
|
|
|
all_pre_day.extend([
|
|
|
|
|
DeferredSessionDecision { created_at: decision_date.and_time(before_end_time), decision_date, decision_index, decision_equity: decision_total_equity.unwrap_or_else(|| portfolio.total_equity()), decision: before_trading_decision },
|
|
|
|
|
DeferredSessionDecision { created_at: decision_date.and_time(opening_end_time), decision_date, decision_index, decision_equity: decision_total_equity.unwrap_or_else(|| portfolio.total_equity()), decision: auction_decision },
|
|
|
|
|
]);
|
|
|
|
|
for mut batch in all_pre_day {
|
|
|
|
|
if batch.decision.is_portfolio_target_only() {
|
|
|
|
|
if let Some(mut previous) = pending_portfolio.take() {
|
|
|
|
|
previous.merge_from(batch);
|
|
|
|
|
pending_portfolio = Some(previous);
|
|
|
|
|
} else {
|
|
|
|
|
pending_portfolio = Some(batch);
|
|
|
|
|
batch.decision.notes.splice(0..0, previous.decision.notes);
|
|
|
|
|
batch.decision.diagnostics.splice(0..0, previous.decision.diagnostics);
|
|
|
|
|
batch.decision.diagnostics.push("unsubmitted_portfolio_target_superseded".into());
|
|
|
|
|
pre_day_telemetry.risk_decisions.append(&mut previous.decision.risk_decisions);
|
|
|
|
|
}
|
|
|
|
|
} else if batch.rebalance || !batch.order_intents.is_empty() || !batch.exit_symbols.is_empty() {
|
|
|
|
|
pending_portfolio = Some(batch);
|
|
|
|
|
} else if batch.decision.rebalance || !batch.decision.order_intents.is_empty() || !batch.decision.exit_symbols.is_empty() {
|
|
|
|
|
pre_day_batches.push(batch);
|
|
|
|
|
} else if let Some(target) = pending_portfolio.as_mut() {
|
|
|
|
|
target.merge_from(batch);
|
|
|
|
|
target.decision.merge_from(batch.decision);
|
|
|
|
|
} else {
|
|
|
|
|
pre_day_telemetry.notes.append(&mut batch.notes);
|
|
|
|
|
pre_day_telemetry.diagnostics.append(&mut batch.diagnostics);
|
|
|
|
|
pre_day_telemetry.risk_decisions.append(&mut batch.risk_decisions);
|
|
|
|
|
pre_day_telemetry.notes.append(&mut batch.decision.notes);
|
|
|
|
|
pre_day_telemetry.diagnostics.append(&mut batch.decision.diagnostics);
|
|
|
|
|
pre_day_telemetry.risk_decisions.append(&mut batch.decision.risk_decisions);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let original_minute_clock = should_run_minute_events(&intraday_schedule_rules, &self.subscriptions);
|
|
|
|
@@ -3000,6 +3141,7 @@ where
|
|
|
|
|
_ => NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
deferred_day_time = deferred_day_time.map(|time| time.max(opening_end_time));
|
|
|
|
|
let mut deferred_auction = deferred_day_time.map(|_| std::mem::take(&mut pre_day_batches));
|
|
|
|
|
let mut report = if deferred_day_time.is_some() { BrokerExecutionReport::default() } else { self.broker.execute_before_strategy_at_clock(
|
|
|
|
|
execution_date,
|
|
|
|
@@ -3028,7 +3170,7 @@ where
|
|
|
|
|
&self.subscriptions,
|
|
|
|
|
&mut process_events,
|
|
|
|
|
&mut report.process_events,
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))),
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))),
|
|
|
|
|
)?;
|
|
|
|
|
publish_phase_event(
|
|
|
|
|
&mut self.strategy,
|
|
|
|
@@ -3046,7 +3188,7 @@ where
|
|
|
|
|
execution_date,
|
|
|
|
|
ProcessEventKind::PostOpenAuction,
|
|
|
|
|
"open_auction:post",
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, default_stage_time(ScheduleStage::OpenAuction))),
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, Some(opening_end_time))),
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
Self::record_execution_history(&mut result, &mut report, decision_date, execution_date);
|
|
|
|
@@ -3181,14 +3323,18 @@ where
|
|
|
|
|
if deferred_day_time == Some(minute_time) {
|
|
|
|
|
deferred_day_time = None;
|
|
|
|
|
let mut before_batches = deferred_auction.take().unwrap_or_default();
|
|
|
|
|
if before_batches.is_empty() {before_batches.push(StrategyDecision::default());}
|
|
|
|
|
if before_batches.is_empty() {before_batches.push(DeferredSessionDecision {
|
|
|
|
|
created_at: decision_date.and_time(minute_time), decision_date, decision_index,
|
|
|
|
|
decision_equity: decision_total_equity.unwrap_or_else(|| portfolio.total_equity()), decision: StrategyDecision::default(),
|
|
|
|
|
});}
|
|
|
|
|
for mut auction in before_batches {
|
|
|
|
|
let open_orders = self.open_order_views();
|
|
|
|
|
self.ensure_execution_quotes_for_decision(execution_date,decision_date,&portfolio,&open_orders,&auction,Some(minute_time),Some(minute_time))?;
|
|
|
|
|
self.ensure_execution_quotes_for_decision(execution_date,auction.created_at.date(),&portfolio,&open_orders,&auction.decision,Some(minute_time),Some(minute_time))?;
|
|
|
|
|
let mut batch = self.broker.execute_before_strategy_at_clock(
|
|
|
|
|
execution_date, decision_date, decision_date, decision_total_equity,
|
|
|
|
|
&mut portfolio, &self.data, &auction, Some(minute_time),
|
|
|
|
|
execution_date, auction.decision_date, auction.created_at.date(), Some(auction.decision_equity),
|
|
|
|
|
&mut portfolio, &self.data, &auction.decision, Some(minute_time),
|
|
|
|
|
)?;
|
|
|
|
|
annotate_broker_report_dates(&mut batch, auction.decision_date, auction.created_at.date(), execution_date);
|
|
|
|
|
Self::record_execution_history(&mut result, &mut batch, decision_date, execution_date);
|
|
|
|
|
let orders = self.open_order_views();
|
|
|
|
|
publish_process_events(&mut self.strategy, &mut self.process_event_bus,
|
|
|
|
@@ -3199,9 +3345,9 @@ where
|
|
|
|
|
)?;
|
|
|
|
|
merge_broker_report(&mut report, batch);
|
|
|
|
|
Self::record_execution_history(&mut result, &mut report, decision_date, execution_date);
|
|
|
|
|
decision.notes.append(&mut auction.notes);
|
|
|
|
|
decision.diagnostics.append(&mut auction.diagnostics);
|
|
|
|
|
decision.risk_decisions.append(&mut auction.risk_decisions);
|
|
|
|
|
decision.notes.append(&mut auction.decision.notes);
|
|
|
|
|
decision.diagnostics.append(&mut auction.decision.diagnostics);
|
|
|
|
|
decision.risk_decisions.append(&mut auction.decision.risk_decisions);
|
|
|
|
|
}
|
|
|
|
|
decision.merge_from(self.execute_day_phase(
|
|
|
|
|
(execution_date, decision_date, decision_index, decision_total_equity),
|
|
|
|
@@ -3444,7 +3590,7 @@ where
|
|
|
|
|
).map(|(_, end)| end);
|
|
|
|
|
let after_trading_time = default_stage_time(ScheduleStage::AfterTrading)
|
|
|
|
|
.into_iter().chain(last_execution_time).chain(post_close_end).max();
|
|
|
|
|
let settlement_time = default_stage_time(ScheduleStage::Settlement)
|
|
|
|
|
let mut settlement_time = default_stage_time(ScheduleStage::Settlement)
|
|
|
|
|
.into_iter().chain(after_trading_time).max();
|
|
|
|
|
self.observe_manual_until(&mut manual_cursor, execution_date.and_time(after_trading_time.expect("after-trading clock")),
|
|
|
|
|
&mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?;
|
|
|
|
@@ -3521,41 +3667,10 @@ where
|
|
|
|
|
"after_trading",
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)),
|
|
|
|
|
)?;
|
|
|
|
|
let mut after_trading_decision = collect_scheduled_decisions_for_stage(
|
|
|
|
|
&mut self.strategy,
|
|
|
|
|
&scheduler,
|
|
|
|
|
execution_date,
|
|
|
|
|
ScheduleStage::AfterTrading,
|
|
|
|
|
&coarse_schedule_rules,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_index,
|
|
|
|
|
&self.data,
|
|
|
|
|
&portfolio,
|
|
|
|
|
self.futures_account.as_ref(),
|
|
|
|
|
&post_trade_open_orders,
|
|
|
|
|
self.dynamic_universe.as_ref(),
|
|
|
|
|
&self.subscriptions,
|
|
|
|
|
&mut process_events,
|
|
|
|
|
&mut self.process_event_bus,
|
|
|
|
|
result.order_events.as_slice(),
|
|
|
|
|
result.fills.as_slice(),
|
|
|
|
|
after_trading_time,
|
|
|
|
|
after_trading_time,
|
|
|
|
|
)?;
|
|
|
|
|
self.apply_strategy_directives(
|
|
|
|
|
execution_date,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_index,
|
|
|
|
|
&mut portfolio,
|
|
|
|
|
&mut process_events,
|
|
|
|
|
&mut after_trading_decision,
|
|
|
|
|
&mut directive_report,
|
|
|
|
|
&mut result,
|
|
|
|
|
stage_datetime(execution_date, after_trading_time),
|
|
|
|
|
)?;
|
|
|
|
|
let mut close_report = self.broker.after_trading(execution_date);
|
|
|
|
|
Self::record_execution_history(&mut result, &mut directive_report, decision_date, execution_date);
|
|
|
|
|
Self::record_execution_history(&mut result, &mut close_report, decision_date, execution_date);
|
|
|
|
|
let post_trade_open_orders = self.open_order_views();
|
|
|
|
|
publish_process_events(
|
|
|
|
|
&mut self.strategy,
|
|
|
|
|
&mut self.process_event_bus,
|
|
|
|
@@ -3572,6 +3687,13 @@ where
|
|
|
|
|
&mut close_report.process_events,
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)),
|
|
|
|
|
)?;
|
|
|
|
|
let (after_trading_decision, after_end_time) = self.collect_lifecycle_schedules(
|
|
|
|
|
&scheduler, execution_date, decision_date, decision_index, ScheduleStage::AfterTrading, &coarse_schedule_rules,
|
|
|
|
|
after_trading_time.expect("after-trading clock"), None, after_trading_time,
|
|
|
|
|
&mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report,
|
|
|
|
|
)?;
|
|
|
|
|
decision.merge_from(after_trading_decision);
|
|
|
|
|
settlement_time = Some(settlement_time.expect("settlement clock").max(after_end_time));
|
|
|
|
|
report.order_events.extend(close_report.order_events);
|
|
|
|
|
report.fill_events.extend(close_report.fill_events);
|
|
|
|
|
report.position_events.extend(close_report.position_events);
|
|
|
|
@@ -3601,7 +3723,7 @@ where
|
|
|
|
|
execution_date,
|
|
|
|
|
ProcessEventKind::PostAfterTrading,
|
|
|
|
|
"after_trading:post",
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, after_trading_time)),
|
|
|
|
|
CallbackObservation::from_result(&result, Some(execution_date.and_time(after_end_time))),
|
|
|
|
|
)?;
|
|
|
|
|
self.observe_manual_until(&mut manual_cursor, execution_date.and_time(settlement_time.expect("settlement clock")),
|
|
|
|
|
&mut portfolio, &mut result, &mut process_events, Some((decision_date, decision_index)))?;
|
|
|
|
@@ -3660,38 +3782,13 @@ where
|
|
|
|
|
"settlement",
|
|
|
|
|
CallbackObservation::from_result(&result, stage_datetime(execution_date, settlement_time)),
|
|
|
|
|
)?;
|
|
|
|
|
let mut settlement_decision = collect_scheduled_decisions_for_stage(
|
|
|
|
|
&mut self.strategy,
|
|
|
|
|
&scheduler,
|
|
|
|
|
execution_date,
|
|
|
|
|
ScheduleStage::Settlement,
|
|
|
|
|
&coarse_schedule_rules,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_index,
|
|
|
|
|
&self.data,
|
|
|
|
|
&portfolio,
|
|
|
|
|
self.futures_account.as_ref(),
|
|
|
|
|
&post_close_open_orders,
|
|
|
|
|
self.dynamic_universe.as_ref(),
|
|
|
|
|
&self.subscriptions,
|
|
|
|
|
&mut process_events,
|
|
|
|
|
&mut self.process_event_bus,
|
|
|
|
|
result.order_events.as_slice(),
|
|
|
|
|
result.fills.as_slice(),
|
|
|
|
|
settlement_time,
|
|
|
|
|
settlement_time,
|
|
|
|
|
)?;
|
|
|
|
|
self.apply_strategy_directives(
|
|
|
|
|
execution_date,
|
|
|
|
|
decision_date,
|
|
|
|
|
decision_index,
|
|
|
|
|
&mut portfolio,
|
|
|
|
|
&mut process_events,
|
|
|
|
|
&mut settlement_decision,
|
|
|
|
|
&mut directive_report,
|
|
|
|
|
&mut result,
|
|
|
|
|
stage_datetime(execution_date, settlement_time),
|
|
|
|
|
let (settlement_decision, settlement_end_time) = self.collect_lifecycle_schedules(
|
|
|
|
|
&scheduler, execution_date, decision_date, decision_index, ScheduleStage::Settlement, &coarse_schedule_rules,
|
|
|
|
|
settlement_time.expect("settlement clock"), None, settlement_time,
|
|
|
|
|
&mut portfolio, &mut manual_cursor, &mut result, &mut process_events, &mut directive_report,
|
|
|
|
|
)?;
|
|
|
|
|
decision.merge_from(settlement_decision);
|
|
|
|
|
settlement_time = Some(settlement_end_time);
|
|
|
|
|
let futures_daily_settlement_report = self.settle_futures_daily(execution_date);
|
|
|
|
|
merge_broker_report(&mut directive_report, futures_daily_settlement_report);
|
|
|
|
|
let futures_expiration_report = self.settle_futures_expirations(execution_date);
|
|
|
|
@@ -3868,7 +3965,7 @@ where
|
|
|
|
|
if let Some(last_date) = execution_dates.last().copied() {
|
|
|
|
|
result.holdings_summary = portfolio.holdings_summary(last_date);
|
|
|
|
|
}
|
|
|
|
|
result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied());
|
|
|
|
|
result.terminal_audit = self.terminal_audit(&portfolio, execution_dates.last().copied())?;
|
|
|
|
|
result.metrics = compute_backtest_metrics_with_manual(
|
|
|
|
|
&result.equity_curve,
|
|
|
|
|
&result.fills,
|
|
|
|
@@ -5389,6 +5486,207 @@ mod tests {
|
|
|
|
|
assert!((result.metrics.average_daily_turnover - 6000. / 9991.).abs() < 1e-12);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn opening_and_late_lifecycle_schedules_do_not_read_past_or_future_manual_state() {
|
|
|
|
|
struct Probe { seen: Rc<RefCell<Vec<(String, NaiveTime, u32)>>> }
|
|
|
|
|
impl Strategy for Probe {
|
|
|
|
|
fn name(&self) -> &str { "lifecycle-manual-clock" }
|
|
|
|
|
fn requires_minute_callbacks(&self) -> bool { false }
|
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> { vec![
|
|
|
|
|
ScheduleRule::daily("opening-first", ScheduleStage::OpenAuction).with_time_rule(ScheduleTimeRule::physical_time(9, 20)),
|
|
|
|
|
ScheduleRule::daily("opening-second", ScheduleStage::OpenAuction).with_time_rule(ScheduleTimeRule::physical_time(9, 26)),
|
|
|
|
|
ScheduleRule::daily("after-first", ScheduleStage::AfterTrading).with_time_rule(ScheduleTimeRule::physical_time(15, 15)),
|
|
|
|
|
ScheduleRule::daily("after-second", ScheduleStage::AfterTrading).with_time_rule(ScheduleTimeRule::physical_time(16, 0)),
|
|
|
|
|
ScheduleRule::daily("settlement", ScheduleStage::Settlement).with_time_rule(ScheduleTimeRule::physical_time(16, 10)),
|
|
|
|
|
] }
|
|
|
|
|
fn on_day(&mut self, _: &StrategyContext<'_>) -> Result<StrategyDecision, crate::BacktestError> { Ok(Default::default()) }
|
|
|
|
|
fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
|
|
|
|
|
self.seen.borrow_mut().push((rule.name.clone(), ctx.current_time().unwrap(), ctx.portfolio.position(SYMBOL).map_or(0, |p| p.quantity)));
|
|
|
|
|
Ok(Default::default())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let date = d(2026, 6, 1);
|
|
|
|
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
|
|
|
|
let broker = BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open)
|
|
|
|
|
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false)
|
|
|
|
|
.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap());
|
|
|
|
|
let config = BacktestConfig { initial_cash:10000., benchmark_code:"000852.SH".into(), start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0, execution_price_field:PriceField::Open };
|
|
|
|
|
BacktestEngine::new(clock_probe_data(date, &[]), Probe { seen:seen.clone() }, broker, config)
|
|
|
|
|
.with_observed_manual_executions(observed_manual_replay(date, &[(9,22),(15,30),(16,5)])).unwrap().run().unwrap();
|
|
|
|
|
let values = seen.borrow();
|
|
|
|
|
for (name, expected) in [("opening-first",0),("opening-second",100),("after-first",100),("after-second",200),("settlement",300)] {
|
|
|
|
|
assert_eq!(values.iter().find(|value| value.0 == name).unwrap().2, expected, "{name}: {values:?}");
|
|
|
|
|
}
|
|
|
|
|
assert!(values.windows(2).all(|pair| pair[0].1 <= pair[1].1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LateIntentProbe {
|
|
|
|
|
origin: NaiveDate,
|
|
|
|
|
complete_target: bool,
|
|
|
|
|
replace_with_zero: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Strategy for LateIntentProbe {
|
|
|
|
|
fn name(&self) -> &str { "late-intent-probe" }
|
|
|
|
|
fn requires_minute_callbacks(&self) -> bool { false }
|
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
|
|
|
vec![
|
|
|
|
|
ScheduleRule::daily("late", ScheduleStage::AfterTrading)
|
|
|
|
|
.with_time_rule(ScheduleTimeRule::physical_time(16, 0)),
|
|
|
|
|
ScheduleRule::daily("last", ScheduleStage::Settlement)
|
|
|
|
|
.with_time_rule(ScheduleTimeRule::physical_time(16, 10)),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, crate::BacktestError> {
|
|
|
|
|
if self.replace_with_zero && ctx.execution_date > self.origin {
|
|
|
|
|
Ok(StrategyDecision { rebalance: true, ..Default::default() })
|
|
|
|
|
} else { Ok(Default::default()) }
|
|
|
|
|
}
|
|
|
|
|
fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, rule: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
|
|
|
|
|
if ctx.execution_date != self.origin { return Ok(Default::default()); }
|
|
|
|
|
if self.complete_target {
|
|
|
|
|
// The settlement target replaces the earlier complete target.
|
|
|
|
|
Ok(StrategyDecision { rebalance: true,
|
|
|
|
|
target_weights: [(SYMBOL.into(), if rule.name == "late" { 0.2 } else { 0.3 })].into(),
|
|
|
|
|
..Default::default() })
|
|
|
|
|
} else {
|
|
|
|
|
Ok(StrategyDecision { order_intents: vec![OrderIntent::Shares {
|
|
|
|
|
symbol: SYMBOL.into(), quantity: if rule.name == "late" {100} else {200}, reason: rule.name.clone(),
|
|
|
|
|
}], ..Default::default() })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn late_intent_run(end: NaiveDate, lag: usize, complete_target: bool, replace_with_zero: bool) -> super::BacktestResult {
|
|
|
|
|
let dates = [d(2026,6,1), d(2026,6,2), d(2026,6,3), d(2026,6,4)];
|
|
|
|
|
let origin = dates[lag];
|
|
|
|
|
let data = dataset_from_market_and_candidates(
|
|
|
|
|
dates.iter().map(|&date| market(date, if date > origin {12.} else {10.}, if date > origin {12.} else {10.})).collect(),
|
|
|
|
|
dates.iter().map(|&date| candidate(date)).collect(),
|
|
|
|
|
);
|
|
|
|
|
let broker = BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open)
|
|
|
|
|
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false);
|
|
|
|
|
let config = BacktestConfig { initial_cash:10000., benchmark_code:"000852.SH".into(), start_date:Some(dates[0]), end_date:Some(end), decision_lag_trading_days:lag, execution_price_field:PriceField::Open };
|
|
|
|
|
BacktestEngine::new(data, LateIntentProbe {origin, complete_target, replace_with_zero}, broker, config).run().unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn late_imperative_orders_execute_once_in_next_session_with_original_dates_and_new_price() {
|
|
|
|
|
for lag in [0,1] {
|
|
|
|
|
let result = late_intent_run(d(2026,6,4), lag, false, false);
|
|
|
|
|
let origin = d(2026,6,1) + chrono::Duration::days(lag as i64);
|
|
|
|
|
let execution = origin.succ_opt().unwrap();
|
|
|
|
|
assert_eq!(result.fills.len(), 2, "lag={lag}: {:?}", result.order_events);
|
|
|
|
|
assert_eq!(result.fills.iter().map(|fill| fill.quantity).collect::<Vec<_>>(), vec![100,200]);
|
|
|
|
|
for fill in &result.fills {
|
|
|
|
|
assert_eq!(fill.date, execution);
|
|
|
|
|
assert_eq!(fill.execution_date, Some(execution));
|
|
|
|
|
assert_eq!(fill.decision_date, Some(d(2026,6,1)));
|
|
|
|
|
assert_eq!(fill.order_created_date, Some(origin));
|
|
|
|
|
assert_eq!(fill.price, 12., "must not reuse the signal-day price");
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(result.terminal_audit.deferred_strategy_decision_count, 0);
|
|
|
|
|
assert_eq!(result.holdings_summary[0].quantity, 300);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn late_complete_targets_use_latest_target_and_next_day_zero_supersedes_them() {
|
|
|
|
|
let result = late_intent_run(d(2026,6,4), 0, true, false);
|
|
|
|
|
assert_eq!(result.fills.len(), 1);
|
|
|
|
|
assert_eq!(result.fills[0].quantity, 200);
|
|
|
|
|
assert_eq!(result.fills[0].price, 12.);
|
|
|
|
|
let zero = late_intent_run(d(2026,6,4), 0, true, true);
|
|
|
|
|
assert!(zero.fills.is_empty(), "an obsolete buy must not run before the current zero target");
|
|
|
|
|
assert!(zero.holdings_summary.is_empty());
|
|
|
|
|
assert_eq!(zero.terminal_audit.deferred_strategy_decision_count, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn final_day_late_intents_are_retained_without_orders_or_fills_past_the_end() {
|
|
|
|
|
let result = late_intent_run(d(2026,6,1), 0, false, false);
|
|
|
|
|
assert!(result.fills.is_empty());
|
|
|
|
|
assert!(result.order_events.is_empty());
|
|
|
|
|
let audit = &result.terminal_audit;
|
|
|
|
|
assert_eq!(audit.status, super::BacktestTerminalStatus::CompletedWithPendingState);
|
|
|
|
|
assert_eq!(audit.deferred_strategy_decision_count, 2);
|
|
|
|
|
assert_eq!(audit.deferred_strategy_decisions.len(), 2);
|
|
|
|
|
assert_eq!(audit.deferred_strategy_decisions[0]["created_at"], "2026-06-01T16:00:00");
|
|
|
|
|
assert_eq!(audit.deferred_strategy_decisions[1]["decision"]["order_intents"][0]["Shares"]["quantity"], 200);
|
|
|
|
|
let latest = late_intent_run(d(2026,6,1), 0, true, false);
|
|
|
|
|
assert_eq!(latest.terminal_audit.deferred_strategy_decision_count, 1);
|
|
|
|
|
assert_eq!(latest.terminal_audit.deferred_strategy_decisions[0]["decision"]["target_weights"][SYMBOL], 0.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn late_intents_do_not_wait_for_another_selection_snapshot() {
|
|
|
|
|
let dates = [d(2026,6,1), d(2026,6,2), d(2026,6,3)];
|
|
|
|
|
let data = DataSet::from_components(vec![default_instrument()],
|
|
|
|
|
dates.iter().map(|&date| market(date, if date == dates[0] {10.} else {12.}, 12.)).collect(),
|
|
|
|
|
vec![factor(dates[0]), factor(dates[2])],
|
|
|
|
|
dates.iter().map(|&date| candidate(date)).collect(),
|
|
|
|
|
dates.iter().map(|&date| benchmark(date)).collect()).unwrap();
|
|
|
|
|
let broker = BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open)
|
|
|
|
|
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false);
|
|
|
|
|
let config = BacktestConfig { initial_cash:10000., benchmark_code:"000852.SH".into(), start_date:Some(dates[0]), end_date:Some(dates[2]), decision_lag_trading_days:0, execution_price_field:PriceField::Open };
|
|
|
|
|
let strategy = LateIntentProbe {origin:dates[0], complete_target:false, replace_with_zero:false};
|
|
|
|
|
assert_eq!(super::backtest_execution_dates_with_rules(&data, Some(dates[0]), Some(dates[2]), 0, &strategy.schedule_rules()), dates);
|
|
|
|
|
let result = BacktestEngine::new(data, strategy, broker, config).run().unwrap();
|
|
|
|
|
assert_eq!(result.fills.len(), 2);
|
|
|
|
|
assert!(result.fills.iter().all(|fill| fill.date == dates[1] && fill.price == 12.));
|
|
|
|
|
assert!(result.equity_curve.iter().any(|point| point.date == dates[1] && point.diagnostics.contains("deferred_strategy_session_no_new_signal")));
|
|
|
|
|
assert_eq!(result.terminal_audit.deferred_strategy_decision_count, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn opening_schedule_cannot_advance_past_the_intraday_phase() {
|
|
|
|
|
struct InvalidOpening;
|
|
|
|
|
impl Strategy for InvalidOpening {
|
|
|
|
|
fn name(&self) -> &str { "invalid-opening" }
|
|
|
|
|
fn requires_minute_callbacks(&self) -> bool { false }
|
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
|
|
|
vec![ScheduleRule::daily("too-late", ScheduleStage::OpenAuction).with_time_rule(ScheduleTimeRule::physical_time(10,0))]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let date = d(2026,6,1);
|
|
|
|
|
let broker = BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open)
|
|
|
|
|
.with_matching_type(MatchingType::NextBarOpen).with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap());
|
|
|
|
|
let config = BacktestConfig { initial_cash:10000., benchmark_code:"000852.SH".into(), start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0, execution_price_field:PriceField::Open };
|
|
|
|
|
let error = BacktestEngine::new(clock_probe_data(date, &[]), InvalidOpening, broker, config).run().unwrap_err();
|
|
|
|
|
assert!(error.to_string().contains("open_auction schedule overlaps"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn after_trading_cancel_is_executed_as_a_control_not_a_next_session_order() {
|
|
|
|
|
struct LateCancel;
|
|
|
|
|
impl Strategy for LateCancel {
|
|
|
|
|
fn name(&self) -> &str { "late-cancel" }
|
|
|
|
|
fn requires_minute_callbacks(&self) -> bool { false }
|
|
|
|
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
|
|
|
|
vec![ScheduleRule::daily("cancel", ScheduleStage::AfterTrading)
|
|
|
|
|
.with_time_rule(ScheduleTimeRule::physical_time(16,0))]
|
|
|
|
|
}
|
|
|
|
|
fn on_day(&mut self, _: &StrategyContext<'_>) -> Result<StrategyDecision, crate::BacktestError> {
|
|
|
|
|
Ok(StrategyDecision { order_intents: vec![OrderIntent::LimitShares {
|
|
|
|
|
symbol:SYMBOL.into(), quantity:100, limit_price:5., reason:"resting".into(),
|
|
|
|
|
}.with_time_in_force(crate::strategy::OrderTimeInForce::Gtc)], ..Default::default() })
|
|
|
|
|
}
|
|
|
|
|
fn on_scheduled(&mut self, ctx: &StrategyContext<'_>, _: &ScheduleRule) -> Result<StrategyDecision, crate::BacktestError> {
|
|
|
|
|
assert_eq!(ctx.open_orders.len(), 1);
|
|
|
|
|
Ok(StrategyDecision { order_intents: vec![OrderIntent::CancelAll {reason:"late-cancel".into()}], ..Default::default() })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let date = d(2026,6,1);
|
|
|
|
|
let broker = BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Open)
|
|
|
|
|
.with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false);
|
|
|
|
|
let config = BacktestConfig {initial_cash:10000., benchmark_code:"000852.SH".into(), start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0, execution_price_field:PriceField::Open};
|
|
|
|
|
let result = BacktestEngine::new(clock_probe_data(date, &[]), LateCancel, broker, config).run().unwrap();
|
|
|
|
|
assert!(result.fills.is_empty());
|
|
|
|
|
assert!(result.order_events.iter().any(|order| order.status == OrderStatus::Canceled && order.reason.contains("late-cancel")));
|
|
|
|
|
assert_eq!(result.terminal_audit.stock_open_order_count, 0);
|
|
|
|
|
assert_eq!(result.terminal_audit.deferred_strategy_decision_count, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn early_pre_open_schedules_observe_manual_receipts_in_chronological_order() {
|
|
|
|
|
struct Probe { seen: Rc<RefCell<Vec<(NaiveTime, u32)>>> }
|
|
|
|
|