修复多时点调度与触发价撮合

This commit is contained in:
boris
2026-08-28 11:44:31 +08:00
parent 5bbb093f47
commit 59be3b5dd5
3 changed files with 225 additions and 32 deletions
+20
View File
@@ -529,6 +529,18 @@ impl<C, R> BrokerSimulator<C, R> {
self
}
/// Override the price source after selecting the order-matching contract.
///
/// A scheduled daily `current_bar_close` order uses the latest completed
/// intraday quote at its actual trigger time, while an unscheduled daily
/// order uses the official daily close. Keeping this as an explicit final
/// builder step prevents `with_matching_type` from silently erasing the
/// resolved runner contract.
pub fn with_execution_price_field(mut self, execution_price_field: PriceField) -> Self {
self.execution_price_field = execution_price_field;
self
}
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
self.slippage_model = slippage_model;
self
@@ -7073,6 +7085,14 @@ where
.or(runtime_end_time)
.map(|end_time| date.and_time(end_time))
});
let end_cursor = if end_cursor.is_none()
&& matching_type == MatchingType::CurrentBarClose
&& self.execution_price_field == PriceField::Last
{
start_cursor
} else {
end_cursor
};
let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill_with_ledger(
+204 -31
View File
@@ -2221,6 +2221,25 @@ where
let mut directive_report = BrokerExecutionReport::default();
let pre_open_orders = self.open_order_views();
let schedule_rules = self.strategy.schedule_rules();
let keep_timed_rules_on_coarse_stage =
self.broker.matching_type() == MatchingType::NextBarOpen;
let coarse_schedule_rules = schedule_rules
.iter()
.filter(|rule| {
keep_timed_rules_on_coarse_stage
|| !is_on_day_or_bar_physical_time_rule(rule)
})
.cloned()
.collect::<Vec<_>>();
let intraday_schedule_rules = schedule_rules
.iter()
.filter(|rule| {
rule.stage == ScheduleStage::Minute
|| (!keep_timed_rules_on_coarse_stage
&& is_on_day_or_bar_physical_time_rule(rule))
})
.cloned()
.collect::<Vec<_>>();
publish_phase_event(
&mut self.strategy,
&mut self.process_event_bus,
@@ -2279,7 +2298,7 @@ where
&scheduler,
execution_date,
ScheduleStage::BeforeTrading,
&schedule_rules,
&coarse_schedule_rules,
decision_date,
decision_index,
&self.data,
@@ -2342,7 +2361,7 @@ where
&scheduler,
execution_date,
ScheduleStage::OpenAuction,
&schedule_rules,
&coarse_schedule_rules,
decision_date,
decision_index,
&self.data,
@@ -2535,7 +2554,7 @@ where
&scheduler,
execution_date,
ScheduleStage::OnDay,
&schedule_rules,
&coarse_schedule_rules,
decision_date,
decision_index,
&self.data,
@@ -2589,7 +2608,7 @@ where
&scheduler,
execution_date,
ScheduleStage::Bar,
&schedule_rules,
&coarse_schedule_rules,
decision_date,
decision_index,
&self.data,
@@ -2727,7 +2746,7 @@ where
"bar:post",
)?;
if should_run_minute_events(&schedule_rules, &self.subscriptions) {
if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) {
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
let mut minute_symbols = self.subscriptions.clone();
self.load_missing_execution_quotes(
@@ -2753,7 +2772,7 @@ where
ProcessEventKind::Minute,
ProcessEventKind::PostMinute,
]);
let minute_all_time_rules = schedule_rules
let minute_all_time_rules = intraday_schedule_rules
.iter()
.filter(|rule| {
rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()
@@ -2761,9 +2780,8 @@ where
.cloned()
.collect::<Vec<_>>();
let minute_schedule_all_times = !minute_all_time_rules.is_empty();
let mut minute_schedule_timestamps = schedule_rules
let mut minute_schedule_timestamps = intraday_schedule_rules
.iter()
.filter(|rule| rule.stage == ScheduleStage::Minute)
.filter(|rule| scheduler.is_due_on(decision_date, rule))
.filter_map(|rule| {
let minute = rule.time_rule.as_ref()?.minute_of_day()?;
@@ -2832,30 +2850,38 @@ where
)?;
let mut minute_decision = if schedule_candidate {
let event_rules = if has_specific_schedule {
schedule_rules.as_slice()
intraday_schedule_rules.as_slice()
} else {
minute_all_time_rules.as_slice()
};
collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
let mut scheduled = StrategyDecision::default();
for stage in [
ScheduleStage::OnDay,
ScheduleStage::Bar,
ScheduleStage::Minute,
event_rules,
decision_date,
decision_index,
&self.data,
&portfolio,
self.futures_account.as_ref(),
&minute_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
Some(minute_time),
result.order_events.as_slice(),
result.fills.as_slice(),
)?
] {
scheduled.merge_from(collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
stage,
event_rules,
decision_date,
decision_index,
&self.data,
&portfolio,
self.futures_account.as_ref(),
&minute_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
Some(minute_time),
result.order_events.as_slice(),
result.fills.as_slice(),
)?);
}
scheduled
} else {
crate::strategy::StrategyDecision::default()
};
@@ -3046,7 +3072,7 @@ where
&scheduler,
execution_date,
ScheduleStage::AfterTrading,
&schedule_rules,
&coarse_schedule_rules,
decision_date,
decision_index,
&self.data,
@@ -3174,7 +3200,7 @@ where
&scheduler,
execution_date,
ScheduleStage::Settlement,
&schedule_rules,
&coarse_schedule_rules,
decision_date,
decision_index,
&self.data,
@@ -4373,8 +4399,19 @@ fn next_minute_event_timestamp(
}
}
fn is_on_day_or_bar_physical_time_rule(rule: &ScheduleRule) -> bool {
matches!(rule.stage, ScheduleStage::OnDay | ScheduleStage::Bar)
&& matches!(
rule.time_rule,
Some(crate::scheduler::ScheduleTimeRule::MinuteOfDay(_))
)
}
fn should_run_minute_events(rules: &[ScheduleRule], subscriptions: &BTreeSet<String>) -> bool {
!subscriptions.is_empty() || rules.iter().any(|rule| rule.stage == ScheduleStage::Minute)
!subscriptions.is_empty()
|| rules.iter().any(|rule| {
rule.stage == ScheduleStage::Minute || is_on_day_or_bar_physical_time_rule(rule)
})
}
fn merge_broker_report(target: &mut BrokerExecutionReport, incoming: BrokerExecutionReport) {
@@ -4800,6 +4837,36 @@ mod tests {
}
}
#[derive(Debug)]
struct TimedOnDayBuyStrategy {
rules: Vec<ScheduleRule>,
}
impl Strategy for TimedOnDayBuyStrategy {
fn name(&self) -> &str {
"timed_on_day_buy"
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
self.rules.clone()
}
fn on_scheduled(
&mut self,
_ctx: &StrategyContext<'_>,
rule: &ScheduleRule,
) -> Result<StrategyDecision, super::BacktestError> {
Ok(StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: 100,
reason: rule.name.clone(),
}],
..StrategyDecision::default()
})
}
}
#[derive(Debug)]
struct ScheduledBuyOnDecisionDateStrategy {
rule: ScheduleRule,
@@ -5443,6 +5510,112 @@ mod tests {
);
}
#[test]
fn physical_on_day_rules_keep_each_actual_submission_time() {
let date = d(2026, 7, 6);
let quotes = vec![
IntradayExecutionQuote {
date,
symbol: SYMBOL.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).expect("morning timestamp"),
last_price: 11.0,
bid1: 10.99,
ask1: 11.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 110_000.0,
trading_phase: Some("continuous_auction".to_string()),
},
IntradayExecutionQuote {
date,
symbol: SYMBOL.to_string(),
timestamp: date.and_hms_opt(10, 19, 0).expect("future timestamp"),
last_price: 99.0,
bid1: 98.99,
ask1: 99.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 990_000.0,
trading_phase: Some("continuous_auction".to_string()),
},
IntradayExecutionQuote {
date,
symbol: SYMBOL.to_string(),
timestamp: date.and_hms_opt(15, 10, 0).expect("post-close timestamp"),
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 100_000.0,
trading_phase: Some("post_close_fixed_price".to_string()),
},
];
let data = DataSet::from_components_with_actions_and_quotes(
vec![default_instrument()],
vec![market(date, 10.0, 10.0)],
vec![factor(date)],
vec![candidate(date)],
vec![benchmark(date)],
Vec::new(),
quotes,
)
.expect("timed schedule dataset");
let strategy = TimedOnDayBuyStrategy {
rules: vec![
ScheduleRule::daily("morning", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
ScheduleRule::daily("post_close", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(15, 10)),
],
};
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_matching_type(MatchingType::CurrentBarClose)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(
NaiveTime::from_hms_opt(15, 10, 0).expect("runner fallback time"),
)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
};
let result = BacktestEngine::new(data, strategy, broker, config)
.run()
.expect("timed on-day schedules");
assert_eq!(result.fills.len(), 2, "{result:?}");
assert_eq!(
result.fills[0].execution_timestamp,
date.and_hms_opt(10, 18, 0),
"{:?}",
result.fills,
);
assert_eq!(result.fills[0].price, 11.0);
assert_eq!(
result.fills[0].execution_start_timestamp,
date.and_hms_opt(10, 18, 0)
);
assert_eq!(result.fills[1].execution_timestamp, date.and_hms_opt(15, 10, 0));
assert_eq!(result.fills[1].price, 10.0);
assert_eq!(result.fills[0].reason, "morning");
assert_eq!(result.fills[1].reason, "post_close");
}
#[test]
fn minute_event_clock_uses_timestamp_order_and_coalesces_equal_heads() {
let date = d(2026, 6, 1);