修复多时点调度与触发价撮合
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user