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

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
+1 -1
View File
@@ -85,7 +85,7 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与
盘后固定价格不是策略类型,也不是 `matchingType`。自 2026-07-06 起,只有实际同日提交时间落在 15:00–15:30 的普通委托才由 broker 进入盘后固定价格执行阶段;15:00–15:04 的委托等待到 15:0515:05–15:30 按官方收盘价和真实盘后成交量撮合,不叠加滑点,未成交余量不跨日。窗口外委托继续沿用连续竞价、当前收盘或下一交易日开盘合同;`next_bar_open` 策略即使在 15:00 生成信号,也不得被改写为同日盘后委托。缺失盘后行情时必须明确不成交,禁止回退全天成交量或 15:00 前分钟行情。 盘后固定价格不是策略类型,也不是 `matchingType`。自 2026-07-06 起,只有实际同日提交时间落在 15:00–15:30 的普通委托才由 broker 进入盘后固定价格执行阶段;15:00–15:04 的委托等待到 15:0515:05–15:30 按官方收盘价和真实盘后成交量撮合,不叠加滑点,未成交余量不跨日。窗口外委托继续沿用连续竞价、当前收盘或下一交易日开盘合同;`next_bar_open` 策略即使在 15:00 生成信号,也不得被改写为同日盘后委托。缺失盘后行情时必须明确不成交,禁止回退全天成交量或 15:00 前分钟行情。
分钟回放使用行情时间戳与策略定时事件的有序合并时钟。显式 `physical_time` 即使该分钟没有预加载行情也必须触发,并由执行层按需查询该时点价格;同一时间戳只形成一个事件,scheduler 回调先于 `on_minute`。多个时间点分别执行,禁止压成最后一个时间或依赖已有 BAR 才触发。 分钟回放使用行情时间戳与策略定时事件的有序合并时钟。`OnDay``Bar``Minute` 阶段只要声明显式 `physical_time`,就必须在各自真实分钟进入同一时间轴,即使该分钟没有预加载行情也必须触发,并由执行层按需查询该时点或之前最新有效价格;同一时间戳只形成一个事件,scheduler 回调先于 `on_minute`日线 `current_bar_close` 无显式时间时使用官方日收盘,有显式时间时使用该触发点的 `Last` 行情,禁止读取下一分钟;多个时间点分别执行,禁止压成最后一个时间、把早盘单改成盘后单或依赖已有 BAR 才触发。`next_bar_open` 的 T 日信号时钟继续留在粗粒度决策阶段,不能延迟到 T+1 的同名分钟。
`holdUntilExit=true``stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。 `holdUntilExit=true``stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
+20
View File
@@ -529,6 +529,18 @@ impl<C, R> BrokerSimulator<C, R> {
self 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 { pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
self.slippage_model = slippage_model; self.slippage_model = slippage_model;
self self
@@ -7073,6 +7085,14 @@ where
.or(runtime_end_time) .or(runtime_end_time)
.map(|end_time| date.and_time(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); let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill_with_ledger( 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 mut directive_report = BrokerExecutionReport::default();
let pre_open_orders = self.open_order_views(); let pre_open_orders = self.open_order_views();
let schedule_rules = self.strategy.schedule_rules(); 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( publish_phase_event(
&mut self.strategy, &mut self.strategy,
&mut self.process_event_bus, &mut self.process_event_bus,
@@ -2279,7 +2298,7 @@ where
&scheduler, &scheduler,
execution_date, execution_date,
ScheduleStage::BeforeTrading, ScheduleStage::BeforeTrading,
&schedule_rules, &coarse_schedule_rules,
decision_date, decision_date,
decision_index, decision_index,
&self.data, &self.data,
@@ -2342,7 +2361,7 @@ where
&scheduler, &scheduler,
execution_date, execution_date,
ScheduleStage::OpenAuction, ScheduleStage::OpenAuction,
&schedule_rules, &coarse_schedule_rules,
decision_date, decision_date,
decision_index, decision_index,
&self.data, &self.data,
@@ -2535,7 +2554,7 @@ where
&scheduler, &scheduler,
execution_date, execution_date,
ScheduleStage::OnDay, ScheduleStage::OnDay,
&schedule_rules, &coarse_schedule_rules,
decision_date, decision_date,
decision_index, decision_index,
&self.data, &self.data,
@@ -2589,7 +2608,7 @@ where
&scheduler, &scheduler,
execution_date, execution_date,
ScheduleStage::Bar, ScheduleStage::Bar,
&schedule_rules, &coarse_schedule_rules,
decision_date, decision_date,
decision_index, decision_index,
&self.data, &self.data,
@@ -2727,7 +2746,7 @@ where
"bar:post", "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() { if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
let mut minute_symbols = self.subscriptions.clone(); let mut minute_symbols = self.subscriptions.clone();
self.load_missing_execution_quotes( self.load_missing_execution_quotes(
@@ -2753,7 +2772,7 @@ where
ProcessEventKind::Minute, ProcessEventKind::Minute,
ProcessEventKind::PostMinute, ProcessEventKind::PostMinute,
]); ]);
let minute_all_time_rules = schedule_rules let minute_all_time_rules = intraday_schedule_rules
.iter() .iter()
.filter(|rule| { .filter(|rule| {
rule.stage == ScheduleStage::Minute && rule.time_rule.is_none() rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()
@@ -2761,9 +2780,8 @@ where
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let minute_schedule_all_times = !minute_all_time_rules.is_empty(); 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() .iter()
.filter(|rule| rule.stage == ScheduleStage::Minute)
.filter(|rule| scheduler.is_due_on(decision_date, rule)) .filter(|rule| scheduler.is_due_on(decision_date, rule))
.filter_map(|rule| { .filter_map(|rule| {
let minute = rule.time_rule.as_ref()?.minute_of_day()?; let minute = rule.time_rule.as_ref()?.minute_of_day()?;
@@ -2832,30 +2850,38 @@ where
)?; )?;
let mut minute_decision = if schedule_candidate { let mut minute_decision = if schedule_candidate {
let event_rules = if has_specific_schedule { let event_rules = if has_specific_schedule {
schedule_rules.as_slice() intraday_schedule_rules.as_slice()
} else { } else {
minute_all_time_rules.as_slice() minute_all_time_rules.as_slice()
}; };
collect_scheduled_decisions( let mut scheduled = StrategyDecision::default();
&mut self.strategy, for stage in [
&scheduler, ScheduleStage::OnDay,
execution_date, ScheduleStage::Bar,
ScheduleStage::Minute, ScheduleStage::Minute,
event_rules, ] {
decision_date, scheduled.merge_from(collect_scheduled_decisions(
decision_index, &mut self.strategy,
&self.data, &scheduler,
&portfolio, execution_date,
self.futures_account.as_ref(), stage,
&minute_open_orders, event_rules,
self.dynamic_universe.as_ref(), decision_date,
&self.subscriptions, decision_index,
&mut process_events, &self.data,
&mut self.process_event_bus, &portfolio,
Some(minute_time), self.futures_account.as_ref(),
result.order_events.as_slice(), &minute_open_orders,
result.fills.as_slice(), 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 { } else {
crate::strategy::StrategyDecision::default() crate::strategy::StrategyDecision::default()
}; };
@@ -3046,7 +3072,7 @@ where
&scheduler, &scheduler,
execution_date, execution_date,
ScheduleStage::AfterTrading, ScheduleStage::AfterTrading,
&schedule_rules, &coarse_schedule_rules,
decision_date, decision_date,
decision_index, decision_index,
&self.data, &self.data,
@@ -3174,7 +3200,7 @@ where
&scheduler, &scheduler,
execution_date, execution_date,
ScheduleStage::Settlement, ScheduleStage::Settlement,
&schedule_rules, &coarse_schedule_rules,
decision_date, decision_date,
decision_index, decision_index,
&self.data, &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 { 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) { 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)] #[derive(Debug)]
struct ScheduledBuyOnDecisionDateStrategy { struct ScheduledBuyOnDecisionDateStrategy {
rule: ScheduleRule, 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] #[test]
fn minute_event_clock_uses_timestamp_order_and_coalesces_equal_heads() { fn minute_event_clock_uses_timestamp_order_and_coalesces_equal_heads() {
let date = d(2026, 6, 1); let date = d(2026, 6, 1);