From b6f4b05844f6779e2f032308170c4a87d4f0bca1 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Aug 2026 17:56:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9B=98=E5=90=8E=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E6=A0=BC=E6=92=AE=E5=90=88=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 69 ++++++++++++++++++- .../fidc-core/src/platform_expr_strategy.rs | 2 + .../fidc-core/src/platform_strategy_spec.rs | 6 +- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index ab3e188..fb1463b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -248,6 +248,7 @@ pub enum MatchingType { OpenAuction, CurrentBarClose, NextBarOpen, + PostCloseFixedPrice, MinuteLast, MinuteBestOwn, MinuteBestCounterparty, @@ -545,6 +546,12 @@ impl BrokerSimulator { } fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy { + if self.matching_type == MatchingType::PostCloseFixedPrice { + 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, @@ -983,6 +990,13 @@ where return self.clamp_execution_price(snapshot, side, raw_price); } + // A fixed-price post-close declaration is matched at the official + // close; applying market slippage here would turn it into a different + // order contract. Fees and risk checks still run normally. + if self.matching_type == MatchingType::PostCloseFixedPrice { + 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, @@ -7427,7 +7441,8 @@ where match matching_type { MatchingType::OpenAuction | MatchingType::CurrentBarClose - | MatchingType::NextBarOpen => false, + | MatchingType::NextBarOpen + | MatchingType::PostCloseFixedPrice => false, MatchingType::MinuteLast => self.liquidity_limit, MatchingType::MinuteBestOwn | MatchingType::MinuteBestCounterparty @@ -7473,6 +7488,7 @@ fn execution_price_field_from_matching_type(matching_type: MatchingType) -> Pric MatchingType::OpenAuction => PriceField::DayOpen, MatchingType::CurrentBarClose => PriceField::Close, MatchingType::NextBarOpen => PriceField::Open, + MatchingType::PostCloseFixedPrice => PriceField::Close, MatchingType::MinuteLast | MatchingType::MinuteBestOwn | MatchingType::MinuteBestCounterparty @@ -7953,6 +7969,57 @@ mod tests { assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty)); } + #[test] + fn post_close_fixed_price_uses_daily_close_without_market_slippage() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); + let mut snapshot = limit_test_snapshot(); + snapshot.date = date; + snapshot.timestamp = Some(format!("{date} 15:00:00")); + snapshot.close = 10.0; + snapshot.last_price = 10.0; + snapshot.bid1 = 10.0; + snapshot.ask1 = 10.0; + let mut candidate = limit_test_candidate(true, true); + candidate.date = date; + let mut benchmark = limit_test_benchmark(); + benchmark.date = date; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![snapshot], + Vec::new(), + vec![candidate], + vec![benchmark], + Vec::new(), + Vec::new(), + ) + .expect("valid post-close dataset"); + let broker = BrokerSimulator::new( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + ) + .with_matching_type(MatchingType::PostCloseFixedPrice) + .with_slippage_model(SlippageModel::PriceRatio(0.25)) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let decision = StrategyDecision { + order_intents: vec![OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), + target_value: 2_000.0, + reason: "post_close_buy".to_string(), + }], + ..StrategyDecision::default() + }; + let mut portfolio = PortfolioState::new(20_000.0); + let report = broker + .execute(date, &mut portfolio, &data, &decision) + .expect("post-close execution"); + assert_eq!(report.fill_events.len(), 1, "report={report:?}"); + assert_eq!(report.fill_events[0].price, 10.0); + assert_eq!(report.fill_events[0].quantity, 100); + assert!(!broker.has_open_orders()); + } + #[test] fn next_open_buy_risk_uses_execution_date_not_signal_date() { let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 0e4272d..9dbb20e 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -2194,6 +2194,7 @@ impl PlatformExprStrategy { MatchingType::OpenAuction => PriceField::DayOpen, MatchingType::CurrentBarClose => PriceField::Close, MatchingType::NextBarOpen => PriceField::Open, + MatchingType::PostCloseFixedPrice => PriceField::Close, MatchingType::MinuteLast | MatchingType::MinuteBestOwn | MatchingType::MinuteBestCounterparty @@ -2256,6 +2257,7 @@ impl PlatformExprStrategy { let price_field = match self.config.matching_type { MatchingType::NextBarOpen => PriceField::Open, MatchingType::CurrentBarClose => PriceField::Close, + MatchingType::PostCloseFixedPrice => PriceField::Close, MatchingType::OpenAuction | MatchingType::MinuteLast | MatchingType::MinuteBestOwn diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index f008412..f846846 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1345,9 +1345,10 @@ fn parse_matching_type(value: Option<&str>) -> Result, Stri match normalize_model_name(raw).as_str() { "current_bar_close" => Ok(Some(MatchingType::CurrentBarClose)), "next_bar_open" => Ok(Some(MatchingType::NextBarOpen)), + "post_close_fixed_price" => Ok(Some(MatchingType::PostCloseFixedPrice)), "minute_last" => Ok(Some(MatchingType::MinuteLast)), _ => Err(format!( - "matchingType only supports current_bar_close, next_bar_open, minute_last: {raw}" + "matchingType only supports current_bar_close, next_bar_open, post_close_fixed_price, minute_last: {raw}" )), } } @@ -3660,6 +3661,7 @@ mod tests { for (raw, expected) in [ ("current_bar_close", MatchingType::CurrentBarClose), ("next_bar_open", MatchingType::NextBarOpen), + ("post_close_fixed_price", MatchingType::PostCloseFixedPrice), ("minute_last", MatchingType::MinuteLast), ] { let spec = serde_json::json!({ @@ -3696,7 +3698,7 @@ mod tests { assert!( err.to_string().contains( - "matchingType only supports current_bar_close, next_bar_open, minute_last" + "matchingType only supports current_bar_close, next_bar_open, post_close_fixed_price, minute_last" ), "{err}" );