From 6c47c33cab40e85e2839e0c9f367cb910fd06aa9 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 28 Aug 2026 00:12:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E5=AE=9E=E9=99=85=E5=A7=94=E6=89=98?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E9=80=89=E6=8B=A9=E7=9B=98=E5=90=8E=E6=92=AE?= =?UTF-8?q?=E5=90=88=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + crates/fidc-core/src/broker.rs | 271 ++++++++++++++++++++++++++++++--- crates/fidc-core/src/engine.rs | 96 +++++++++++- 3 files changed, 342 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 41569e5..2066584 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。 +盘后固定价格不是策略类型,也不是 `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 前分钟行情。 + `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 4880f96..57d4739 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -255,6 +255,12 @@ pub enum MatchingType { Twap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EquityExecutionPhase { + ContinuousAuction, + PostCloseFixedPrice, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RebalanceCashMode { SamePointNet, @@ -544,7 +550,81 @@ impl BrokerSimulator { } } - fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy { + fn submission_time(&self) -> Option { + self.runtime_intraday_start_time + .get() + .or(self.intraday_execution_start_time) + } + + fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase { + let effective_date = NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid effective date"); + let window_start = NaiveTime::from_hms_opt(15, 0, 0).expect("valid window start"); + let window_end = NaiveTime::from_hms_opt(15, 30, 0).expect("valid window end"); + let submitted_same_day = self + .runtime_order_created_date + .get() + .is_none_or(|created_date| created_date == date); + + if date >= effective_date + && submitted_same_day + && !matches!( + self.matching_type, + MatchingType::OpenAuction | MatchingType::NextBarOpen + ) + && self + .submission_time() + .is_some_and(|time| time >= window_start && time <= window_end) + { + EquityExecutionPhase::PostCloseFixedPrice + } else { + EquityExecutionPhase::ContinuousAuction + } + } + + fn is_post_close_fixed_price(&self, date: NaiveDate) -> bool { + self.execution_phase(date) == EquityExecutionPhase::PostCloseFixedPrice + } + + fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField { + if self.is_post_close_fixed_price(date) { + PriceField::Close + } else { + self.execution_price_field + } + } + + fn post_close_execution_window( + &self, + date: NaiveDate, + ) -> Option<(NaiveDateTime, NaiveDateTime)> { + self.post_close_execution_quote_window(date) + .map(|(start, end)| (date.and_time(start), date.and_time(end))) + } + + pub(crate) fn post_close_execution_quote_window( + &self, + date: NaiveDate, + ) -> Option<(NaiveTime, NaiveTime)> { + if !self.is_post_close_fixed_price(date) { + return None; + } + let matching_start = NaiveTime::from_hms_opt(15, 5, 0).expect("valid matching start"); + let matching_end = NaiveTime::from_hms_opt(15, 30, 0).expect("valid matching end"); + let submitted_at = self.submission_time()?; + Some((submitted_at.max(matching_start), matching_end)) + } + + fn effective_remainder_policy( + &self, + date: NaiveDate, + allow_pending_limit: bool, + ) -> RemainderPolicy { + if self.is_post_close_fixed_price(date) { + return match self.runtime_time_in_force.get() { + Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, + _ => RemainderPolicy::Cancel, + }; + } match self.runtime_time_in_force.get() { Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill, Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled, @@ -613,15 +693,15 @@ where R: EquityRuleHooks, { fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { - snapshot.buy_price(self.execution_price_field) + snapshot.buy_price(self.effective_execution_price_field(snapshot.date)) } fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { - snapshot.sell_price(self.execution_price_field) + snapshot.sell_price(self.effective_execution_price_field(snapshot.date)) } fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { - snapshot.price(self.execution_price_field) + snapshot.price(self.effective_execution_price_field(snapshot.date)) } fn value_buy_sizing_price( @@ -651,6 +731,9 @@ where symbol: &str, snapshot: &crate::data::DailyMarketSnapshot, ) -> f64 { + if self.is_post_close_fixed_price(date) { + return snapshot.close; + } if self.matching_type == MatchingType::NextBarOpen { let execution_price = snapshot.price(PriceField::Open); if execution_price.is_finite() && execution_price > 0.0 { @@ -907,6 +990,9 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { + if self.is_post_close_fixed_price(date) { + return snapshot.close; + } let start_cursor = self .runtime_intraday_start_time .get() @@ -940,6 +1026,9 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { + if self.is_post_close_fixed_price(snapshot.date) { + return snapshot.close; + } if self.execution_price_field == PriceField::Last && self.intraday_execution_start_time.is_some() { @@ -956,7 +1045,7 @@ where snapshot: &crate::data::DailyMarketSnapshot, side: OrderSide, ) -> f64 { - let price = snapshot.price(self.execution_price_field); + let price = snapshot.price(self.effective_execution_price_field(snapshot.date)); if price.is_finite() && price > 0.0 { price } else { @@ -983,6 +1072,10 @@ where return self.clamp_execution_price(snapshot, side, raw_price); } + if self.is_post_close_fixed_price(snapshot.date) { + return self.clamp_execution_price(snapshot, side, raw_price); + } + let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64)); let mut adjusted = match self.slippage_model { SlippageModel::None => raw_price, @@ -1069,11 +1162,15 @@ where fn select_quote_reference_price( &self, - _snapshot: &crate::data::DailyMarketSnapshot, + snapshot: &crate::data::DailyMarketSnapshot, quote: &IntradayExecutionQuote, side: OrderSide, matching_type: MatchingType, ) -> Option { + if self.is_post_close_fixed_price(snapshot.date) { + return (snapshot.close.is_finite() && snapshot.close > 0.0) + .then_some(snapshot.close); + } let raw_price = match matching_type { MatchingType::MinuteBestOwn => match side { OrderSide::Buy => { @@ -4014,7 +4111,7 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let remainder_policy = self.effective_remainder_policy(allow_pending_limit); + let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit); let Some(position) = portfolio.position(symbol) else { return Ok(()); }; @@ -5725,7 +5822,7 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let remainder_policy = self.effective_remainder_policy(allow_pending_limit); + let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit); if portfolio .position(symbol) .is_none_or(|position| position.quantity == 0) @@ -6925,23 +7022,29 @@ where limit_price: Option, ) -> Option { let matching_type = self.matching_type_for_algo_request(algo_request); - let use_intraday_quotes = - algo_request.is_some() || self.execution_price_field == PriceField::Last; + let post_close_window = self.post_close_execution_window(date); + let use_intraday_quotes = post_close_window.is_some() + || algo_request.is_some() + || self.execution_price_field == PriceField::Last; if !use_intraday_quotes { return None; } let runtime_start_time = self.runtime_intraday_start_time.get(); let runtime_end_time = self.runtime_intraday_end_time.get(); - let start_cursor = algo_request - .and_then(|request| request.start_time) - .or(runtime_start_time) - .or(self.intraday_execution_start_time) - .map(|start_time| date.and_time(start_time)); - let end_cursor = algo_request - .and_then(|request| request.end_time) - .or(runtime_end_time) - .map(|end_time| date.and_time(end_time)); + let start_cursor = post_close_window.map(|window| window.0).or_else(|| { + algo_request + .and_then(|request| request.start_time) + .or(runtime_start_time) + .or(self.intraday_execution_start_time) + .map(|start_time| date.and_time(start_time)) + }); + let end_cursor = post_close_window.map(|window| window.1).or_else(|| { + algo_request + .and_then(|request| request.end_time) + .or(runtime_end_time) + .map(|end_time| date.and_time(end_time)) + }); let quotes = data.execution_quotes_on(date, symbol); if let Some(fill) = self.select_execution_fill_with_ledger( @@ -6965,7 +7068,8 @@ where return Some(fill); } - if algo_request.is_some() + if post_close_window.is_some() + || algo_request.is_some() || runtime_start_time.is_some() || runtime_end_time.is_some() || self.intraday_execution_start_time.is_some() @@ -7088,7 +7192,8 @@ where && start_cursor.is_some() && end_cursor.is_some() && start_cursor == end_cursor; - let use_decision_time_quote = start_cursor.is_some() + let use_decision_time_quote = !self.is_post_close_fixed_price(snapshot.date) + && start_cursor.is_some() && (matching_type == MatchingType::MinuteLast || exact_time_order_quote); let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote { self.latest_known_quote_at_or_before( @@ -7531,9 +7636,11 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { mod tests { use std::collections::BTreeMap; + use chrono::NaiveTime; + use super::{ - BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, OpenOrder, - RebalanceCashMode, SlippageModel, + BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger, + MatchingType, OpenOrder, RebalanceCashMode, SlippageModel, }; use crate::cost::ChinaAShareCostModel; use crate::data::{ @@ -7769,6 +7876,124 @@ mod tests { } } + #[test] + fn post_close_phase_is_derived_from_actual_same_day_submission_time() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); + let before_effective = chrono::NaiveDate::from_ymd_opt(2026, 7, 3).expect("valid date"); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_slippage_model(SlippageModel::PriceRatio(0.25)); + broker.runtime_order_created_date.set(Some(date)); + let mut snapshot = dated_limit_test_snapshot(date); + snapshot.close = 10.0; + snapshot.upper_limit = 20.0; + + for (hour, minute) in [(14, 59), (15, 31)] { + broker + .runtime_intraday_start_time + .set(NaiveTime::from_hms_opt(hour, minute, 0)); + assert_eq!( + broker.execution_phase(date), + EquityExecutionPhase::ContinuousAuction + ); + assert_eq!( + broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), + 12.5 + ); + } + for (hour, minute) in [(15, 0), (15, 5), (15, 30)] { + broker + .runtime_intraday_start_time + .set(NaiveTime::from_hms_opt(hour, minute, 0)); + assert_eq!( + broker.execution_phase(date), + EquityExecutionPhase::PostCloseFixedPrice + ); + assert_eq!( + broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)), + 10.0 + ); + } + broker + .runtime_order_created_date + .set(Some(before_effective)); + assert_eq!( + broker.execution_phase(before_effective), + EquityExecutionPhase::ContinuousAuction + ); + + let next_open = BrokerSimulator::new( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_intraday_execution_start_time( + NaiveTime::from_hms_opt(15, 0, 0).expect("valid signal time"), + ); + next_open + .runtime_order_created_date + .set(Some(date.pred_opt().expect("previous date"))); + assert_eq!( + next_open.execution_phase(date), + EquityExecutionPhase::ContinuousAuction, + "a 15:00 signal for next-open execution is not a same-day post-close order" + ); + } + + #[test] + fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); + let mut snapshot = dated_limit_test_snapshot(date); + snapshot.close = 10.0; + snapshot.last_price = 12.0; + snapshot.bid1 = 11.99; + snapshot.ask1 = 12.01; + snapshot.upper_limit = 20.0; + snapshot.lower_limit = 1.0; + let mut quote_before_matching = limit_test_quote(12.0, 11.99, 12.01); + quote_before_matching.date = date; + quote_before_matching.timestamp = date.and_hms_opt(15, 4, 0).expect("valid timestamp"); + quote_before_matching.volume_delta = 100_000; + let mut quote_at_matching = quote_before_matching.clone(); + quote_at_matching.timestamp = date.and_hms_opt(15, 5, 0).expect("valid timestamp"); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + Vec::new(), + vec![dated_limit_test_candidate(date, false, false, true, true)], + vec![dated_limit_test_benchmark(date)], + Vec::new(), + vec![quote_before_matching, quote_at_matching], + ) + .expect("valid post-close dataset"); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose) + .with_slippage_model(SlippageModel::PriceRatio(0.25)) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut portfolio = PortfolioState::new(100_000.0); + let report = broker + .execute_between( + date, + &mut portfolio, + &data, + &next_open_buy_decision(), + NaiveTime::from_hms_opt(15, 0, 0), + NaiveTime::from_hms_opt(15, 0, 0), + ) + .expect("post-close order executes"); + + assert_eq!(report.fill_events.len(), 1, "{report:?}"); + let fill = &report.fill_events[0]; + assert_eq!(fill.price, 10.0, "fixed-price trading uses official close"); + assert_eq!( + fill.execution_timestamp, + date.and_hms_opt(15, 5, 0), + "15:00 submission must not consume the 15:04 quote" + ); + assert!(!broker.has_open_orders()); + } + #[test] fn minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 3ee1506..46ce428 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -638,15 +638,23 @@ where if self.execution_quote_loader.is_none() { return Ok(()); } + let post_close_window = self + .broker + .post_close_execution_quote_window(execution_date); if self.broker.execution_price_field() != PriceField::Last && !decision_has_algo_execution(decision) + && post_close_window.is_none() { return Ok(()); } let caller_start_time = start_time; let caller_end_time = end_time; - let start_time = caller_start_time.or_else(|| self.broker.intraday_execution_start_time()); + let start_time = post_close_window + .map(|window| window.0) + .or(caller_start_time) + .or_else(|| self.broker.intraday_execution_start_time()); + let end_time = post_close_window.map(|window| window.1).or(caller_end_time); let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders); self.load_missing_execution_quotes(execution_date, start_time, end_time, &mut symbols)?; @@ -4614,15 +4622,16 @@ mod tests { use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; + use std::sync::{Arc, Mutex}; - use chrono::NaiveDate; + use chrono::{NaiveDate, NaiveTime}; use super::{BacktestConfig, BacktestEngine}; - use crate::broker::{BrokerSimulator, MatchingType}; + use crate::broker::{BrokerSimulator, MatchingType, SlippageModel}; use crate::cost::ChinaAShareCostModel; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - PriceField, + IntradayExecutionQuote, PriceField, }; use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; @@ -5302,6 +5311,85 @@ mod tests { .expect("backtest run") } + #[test] + fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() { + let date = d(2026, 7, 6); + let data = dataset_from_market_and_candidates( + vec![market(date, 9.5, 10.0)], + vec![candidate(date)], + ); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::CurrentBarClose) + .with_intraday_execution_start_time( + NaiveTime::from_hms_opt(15, 0, 0).expect("valid submission time"), + ) + .with_slippage_model(SlippageModel::PriceRatio(0.25)) + .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, + }; + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let mut engine = BacktestEngine::new( + data, + BuyWhenDecisionDateStrategy { + decision_date: date, + }, + broker, + config, + ) + .with_execution_quote_loader(move |request| { + captured + .lock() + .expect("request capture lock") + .push((request.start_time, request.end_time)); + Ok(request + .symbols + .into_iter() + .map(|symbol| IntradayExecutionQuote { + date: request.date, + symbol, + timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"), + last_price: 12.0, + bid1: 11.99, + ask1: 12.01, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 120_000.0, + trading_phase: Some("post_close_fixed_price".to_string()), + }) + .collect()) + }); + + let result = engine.run().expect("post-close backtest run"); + + assert_eq!( + requests.lock().expect("request capture lock").as_slice(), + &[( + NaiveTime::from_hms_opt(15, 5, 0), + NaiveTime::from_hms_opt(15, 30, 0), + )] + ); + assert_eq!(result.fills.len(), 1, "{result:?}"); + assert_eq!(result.fills[0].price, 10.0); + assert_eq!( + result.fills[0].execution_timestamp, + date.and_hms_opt(15, 5, 0) + ); + } + #[test] fn compact_progress_keeps_counts_without_event_payload_clones() { let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);