From 1a2e247c8d1e4d51cf3e1b7dd20592a42a05128e Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 28 Aug 2026 01:42:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=88=86=E9=92=9F=E8=A1=8C?= =?UTF-8?q?=E6=83=85=E4=B8=8E=E7=AD=96=E7=95=A5=E5=AE=9A=E6=97=B6=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=97=B6=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + crates/fidc-core/src/engine.rs | 213 +++++++++++++++++++++++++++------ 2 files changed, 181 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 2066584..dde3dd9 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ 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 才触发。 + `holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。 ## 内置微盘策略 diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 46ce428..300c0a6 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Timelike}; +use chrono::{Datelike, Duration, NaiveDate, NaiveTime}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -2747,20 +2747,41 @@ where ProcessEventKind::Minute, ProcessEventKind::PostMinute, ]); - let minute_schedule_all_times = schedule_rules + let minute_all_time_rules = schedule_rules .iter() - .any(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none()); - let minute_schedule_minutes = schedule_rules + .filter(|rule| { + rule.stage == ScheduleStage::Minute && rule.time_rule.is_none() + }) + .cloned() + .collect::>(); + let minute_schedule_all_times = !minute_all_time_rules.is_empty(); + let mut minute_schedule_timestamps = schedule_rules .iter() .filter(|rule| rule.stage == ScheduleStage::Minute) - .filter_map(|rule| rule.time_rule.as_ref()?.minute_of_day()) - .collect::>(); + .filter(|rule| scheduler.is_due_on(decision_date, rule)) + .filter_map(|rule| { + let minute = rule.time_rule.as_ref()?.minute_of_day()?; + NaiveTime::from_hms_opt(minute / 60, minute % 60, 0) + }) + .map(|time| execution_date.and_time(time)) + .collect::>() + .into_iter() + .peekable(); let mut minute_group = Vec::new(); - while let Some(first_quote) = minute_quotes.next() { - let minute_timestamp = first_quote.timestamp; + // Merge the immutable quote stream with clock events. Equal + // timestamps form one event; scheduled callbacks run before + // `on_minute` below. + loop { + let next_quote_timestamp = minute_quotes.peek().map(|quote| quote.timestamp); + let next_schedule_timestamp = minute_schedule_timestamps.peek().copied(); + let Some(minute_timestamp) = next_minute_event_timestamp( + next_quote_timestamp, + next_schedule_timestamp, + ) else { + break; + }; let minute_time = minute_timestamp.time(); minute_group.clear(); - minute_group.push(first_quote); while minute_quotes .peek() .is_some_and(|quote| quote.timestamp == minute_timestamp) @@ -2771,9 +2792,13 @@ where .expect("peeked minute quote must be available"), ); } - let schedule_candidate = minute_schedule_all_times - || minute_schedule_minutes - .contains(&(minute_time.hour() * 60 + minute_time.minute())); + let has_specific_schedule = + next_schedule_timestamp == Some(minute_timestamp); + if has_specific_schedule { + minute_schedule_timestamps.next(); + } + let schedule_candidate = has_specific_schedule + || (minute_schedule_all_times && !minute_group.is_empty()); if !requires_minute_callbacks && !has_minute_process_listeners && !schedule_candidate @@ -2799,26 +2824,35 @@ where ProcessEventKind::PreMinute, format!("minute:{minute_timestamp}:pre"), )?; - let mut minute_decision = collect_scheduled_decisions( - &mut self.strategy, - &scheduler, - execution_date, - ScheduleStage::Minute, - &schedule_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(), - )?; + let mut minute_decision = if schedule_candidate { + let event_rules = if has_specific_schedule { + schedule_rules.as_slice() + } else { + minute_all_time_rules.as_slice() + }; + collect_scheduled_decisions( + &mut self.strategy, + &scheduler, + execution_date, + 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(), + )? + } else { + crate::strategy::StrategyDecision::default() + }; if requires_minute_callbacks { for "e in &minute_group { minute_decision.merge_from(self.strategy.on_minute( @@ -4320,6 +4354,18 @@ fn stage_datetime( time.map(|value| date.and_time(value)) } +fn next_minute_event_timestamp( + quote_timestamp: Option, + schedule_timestamp: Option, +) -> Option { + match (quote_timestamp, schedule_timestamp) { + (Some(quote), Some(schedule)) => Some(quote.min(schedule)), + (Some(quote), None) => Some(quote), + (None, Some(schedule)) => Some(schedule), + (None, None) => None, + } +} + fn should_run_minute_events(rules: &[ScheduleRule], subscriptions: &BTreeSet) -> bool { !subscriptions.is_empty() || rules.iter().any(|rule| rule.stage == ScheduleStage::Minute) } @@ -4624,7 +4670,7 @@ mod tests { use std::rc::Rc; use std::sync::{Arc, Mutex}; - use chrono::{NaiveDate, NaiveTime}; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use super::{BacktestConfig, BacktestEngine}; use crate::broker::{BrokerSimulator, MatchingType, SlippageModel}; @@ -4638,7 +4684,7 @@ mod tests { use crate::portfolio::PortfolioState; use crate::risk_control::{FidcRiskControlConfig, RiskCheckScope}; use crate::rules::ChinaEquityRuleHooks; - use crate::scheduler::{ScheduleRule, ScheduleStage}; + use crate::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; const SYMBOL: &str = "000001.SZ"; @@ -4720,6 +4766,33 @@ mod tests { } } + #[derive(Debug)] + struct MinuteScheduleProbeStrategy { + rules: Vec, + observed: Rc>>, + } + + impl Strategy for MinuteScheduleProbeStrategy { + fn name(&self) -> &str { + "minute_schedule_probe" + } + + fn schedule_rules(&self) -> Vec { + self.rules.clone() + } + + fn on_scheduled( + &mut self, + ctx: &StrategyContext<'_>, + _rule: &ScheduleRule, + ) -> Result { + self.observed + .borrow_mut() + .push(ctx.current_datetime().expect("scheduled event time")); + Ok(StrategyDecision::default()) + } + } + #[derive(Debug)] struct ScheduledBuyOnDecisionDateStrategy { rule: ScheduleRule, @@ -5311,6 +5384,78 @@ mod tests { .expect("backtest run") } + #[test] + fn minute_schedules_fire_at_each_declared_time_without_market_rows_at_those_times() { + let date = d(2026, 6, 1); + let observed = Rc::new(RefCell::new(Vec::new())); + let strategy = MinuteScheduleProbeStrategy { + rules: vec![ + ScheduleRule::daily("first", ScheduleStage::Minute) + .with_time_rule(ScheduleTimeRule::physical_time(10, 17)), + ScheduleRule::daily("second", ScheduleStage::Minute) + .with_time_rule(ScheduleTimeRule::physical_time(10, 18)), + ], + observed: Rc::clone(&observed), + }; + let data = dataset_from_market_and_candidates( + vec![market(date, 10.0, 10.2)], + vec![candidate(date)], + ); + assert!( + data.execution_quotes_on(date, SYMBOL).is_empty(), + "the test must prove clock-driven scheduling without minute rows" + ); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::CurrentBarClose) + .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::Close, + }; + + BacktestEngine::new(data, strategy, broker, config) + .run() + .expect("clock-driven minute schedules"); + + assert_eq!( + observed.borrow().as_slice(), + &[ + date.and_time(NaiveTime::from_hms_opt(10, 17, 0).expect("first time")), + date.and_time(NaiveTime::from_hms_opt(10, 18, 0).expect("second time")), + ] + ); + } + + #[test] + fn minute_event_clock_uses_timestamp_order_and_coalesces_equal_heads() { + let date = d(2026, 6, 1); + let quote = date.and_hms_opt(10, 18, 0).expect("quote time"); + let earlier_schedule = date.and_hms_opt(10, 17, 0).expect("schedule time"); + + assert_eq!( + super::next_minute_event_timestamp(Some(quote), Some(earlier_schedule)), + Some(earlier_schedule) + ); + assert_eq!( + super::next_minute_event_timestamp(Some(quote), Some(quote)), + Some(quote) + ); + assert_eq!( + super::next_minute_event_timestamp(None, Some(earlier_schedule)), + Some(earlier_schedule) + ); + } + #[test] fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() { let date = d(2026, 7, 6);