修正盘后意图保留及手工观察的阶段时序

This commit is contained in:
boris
2026-09-14 12:27:30 +08:00
parent 7f0c6a008a
commit fb8192a286
8 changed files with 657 additions and 151 deletions
+434 -136
View File
@@ -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(&quote_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)>>> }
@@ -0,0 +1,196 @@
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
//! This traverses the original Serialize representation without materializing it.
use serde::{Serialize, Serializer, ser};
#[derive(Clone, Copy)]
struct Finite;
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
value.serialize(Finite)
}
macro_rules! scalar {
($($method:ident: $ty:ty),* $(,)?) => {$(
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
)*};
}
impl Serializer for Finite {
type Ok = ();
type Error = serde_json::Error;
type SerializeSeq = Self;
type SerializeTuple = Self;
type SerializeTupleStruct = Self;
type SerializeTupleVariant = Self;
type SerializeMap = Self;
type SerializeStruct = Self;
type SerializeStructVariant = Self;
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
serialize_str: &str, serialize_bytes: &[u8]);
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
self.serialize_f64(f64::from(value))
}
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
if value.is_finite() {
Ok(())
} else {
Err(ser::Error::custom(
"pending strategy intent contains a non-finite number",
))
}
}
fn serialize_none(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_unit(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(
self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(
self,
_: &'static str,
_: u32,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
}
macro_rules! sequence {
($trait:ident, $method:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
sequence!(SerializeSeq, serialize_element);
sequence!(SerializeTuple, serialize_element);
sequence!(SerializeTupleStruct, serialize_field);
sequence!(SerializeTupleVariant, serialize_field);
impl ser::SerializeMap for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
macro_rules! structure {
($trait:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_field<T: ?Sized + Serialize>(
&mut self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
structure!(SerializeStruct);
structure!(SerializeStructVariant);
#[cfg(test)]
mod tests {
use super::*;
use crate::strategy::{OrderIntent, StrategyDecision};
#[test]
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::LimitTargetPercent {
symbol: "000001.SZ".into(),
target_percent: 0.5,
limit_price: value,
reason: "test".into(),
}
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
],
..Default::default()
};
assert!(validate(&decision).is_err());
assert!(validate(&vec![Some(value)]).is_err());
}
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
}
}
+4 -4
View File
@@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum FuturesDirection {
Long,
Short,
@@ -62,7 +62,7 @@ impl FuturesDirection {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FuturesPositionEffect {
Open,
Close,
@@ -81,7 +81,7 @@ impl FuturesPositionEffect {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Serialize)]
pub struct FuturesContractSpec {
pub contract_multiplier: f64,
pub long_margin_rate: f64,
@@ -190,7 +190,7 @@ impl FuturesTransactionCostModel {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct FuturesOrderIntent {
pub symbol: String,
pub direction: FuturesDirection,
+1 -1
View File
@@ -63,7 +63,7 @@ pub struct HoldingLifecycleEvidence {
pub last_sell_date: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AutomaticTradePermission {
pub buy_denial: Option<&'static str>,
pub sell_denial: Option<&'static str>,
+2 -1
View File
@@ -17,6 +17,7 @@ pub mod engine;
pub mod event_bus;
pub mod events;
pub mod fixed_point;
mod finite_serialization;
pub mod futures;
pub mod instrument;
pub mod metrics;
@@ -62,7 +63,7 @@ pub use engine::{
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention, backtest_execution_dates,
ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
};
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{
+2 -2
View File
@@ -479,7 +479,7 @@ pub struct StockPoolSelection {
pub generation: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct StockPoolDecisionConstraints {
pub execution_date: Option<NaiveDate>,
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
@@ -545,7 +545,7 @@ pub struct StockPoolPlan {
/// A signal-time contract. Only the broker/execution adapter supplies later
/// prices, actual cash and holdings; strategy code never sees those inputs.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct FrozenStockPoolIntent {
pub pool_id: String,
pub signal_date: NaiveDate,
+5 -5
View File
@@ -983,7 +983,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
}
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool,
@@ -1101,13 +1101,13 @@ mod decision_merge_tests {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum AlgoOrderStyle {
Vwap,
Twap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum OrderTimeInForce {
Day,
Ioc,
@@ -1136,7 +1136,7 @@ impl OrderTimeInForce {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize)]
pub enum TargetPortfolioOrderPricing {
LimitPrices(BTreeMap<String, f64>),
AlgoOrder {
@@ -1146,7 +1146,7 @@ pub enum TargetPortfolioOrderPricing {
},
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, serde::Serialize)]
pub enum OrderIntent {
StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,