From 59be3b5dd56b0f50b0f88cbdf311ab3f42669b3a Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 28 Aug 2026 11:44:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E6=97=B6=E7=82=B9?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E4=B8=8E=E8=A7=A6=E5=8F=91=E4=BB=B7=E6=92=AE?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- crates/fidc-core/src/broker.rs | 20 +++ crates/fidc-core/src/engine.rs | 235 ++++++++++++++++++++++++++++----- 3 files changed, 225 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index dde3dd9..0f684cd 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与 盘后固定价格不是策略类型,也不是 `matchingType`。自 2026-07-06 起,只有实际同日提交时间落在 15:00–15:30 的普通委托才由 broker 进入盘后固定价格执行阶段;15:00–15:04 的委托等待到 15:05,15: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` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。 diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e5e0e74..3148b5f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -529,6 +529,18 @@ impl BrokerSimulator { 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( diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index b0fe21d..4029c4a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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::>(); + 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::>(); 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::>(); 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) -> 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, + } + + impl Strategy for TimedOnDayBuyStrategy { + fn name(&self) -> &str { + "timed_on_day_buy" + } + + fn schedule_rules(&self) -> Vec { + self.rules.clone() + } + + fn on_scheduled( + &mut self, + _ctx: &StrategyContext<'_>, + rule: &ScheduleRule, + ) -> Result { + 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);