From 7951ba67e3785333cee979b7a3bcee80667f3929 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 8 Jul 2026 11:07:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=89=A7=E8=A1=8C=E6=97=A5?= =?UTF-8?q?=E9=80=80=E5=B8=82=E7=BC=BA=E8=A1=8C=E6=83=85=E6=8B=92=E5=8D=95?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 153 ++++++++++++++++++++++++--- crates/fidc-core/src/risk_control.rs | 15 +++ 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 1845845..bf00299 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -12,7 +12,7 @@ use crate::events::{ }; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; -use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig}; +use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, RiskCheckScope}; use crate::rules::{EquityRuleHooks, RuleCheck}; use crate::strategy::{ AlgoOrderStyle, OpenOrderView, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, @@ -1878,6 +1878,63 @@ where ); } + fn reject_missing_market_or_execution_risk_order( + &self, + report: &mut BrokerExecutionReport, + date: NaiveDate, + data: &DataSet, + symbol: &str, + side: OrderSide, + requested_quantity: u32, + reason: &str, + ) { + if let Some(unavailable_reason) = + self.missing_market_execution_risk_rejection_reason(date, data, symbol, side) + { + let order_id = self.reserve_order_id(); + Self::reject_unavailable_order( + report, + date, + order_id, + symbol, + side, + requested_quantity, + reason, + unavailable_reason, + false, + ); + return; + } + self.reject_missing_market_snapshot_order( + report, + date, + symbol, + side, + requested_quantity, + reason, + ); + } + + fn missing_market_execution_risk_rejection_reason( + &self, + date: NaiveDate, + data: &DataSet, + symbol: &str, + side: OrderSide, + ) -> Option<&'static str> { + let scope = match side { + OrderSide::Buy => RiskCheckScope::Buy, + OrderSide::Sell => RiskCheckScope::Sell, + }; + ChinaAShareRiskControl::active_status_rejection_reason_with_config( + date, + data.candidate(date, symbol), + data.instrument(symbol), + &self.risk_config, + scope, + ) + } + fn reject_unavailable_order( report: &mut BrokerExecutionReport, date: NaiveDate, @@ -2870,6 +2927,15 @@ where return Ok(()); }; let Some(snapshot) = data.market(date, symbol) else { + let unavailable_reason = self + .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Sell) + .map(str::to_string) + .unwrap_or_else(|| { + format!( + "market snapshot is missing for price field {}", + price_field_name(self.execution_price_field) + ) + }); Self::reject_unavailable_order( report, date, @@ -2878,10 +2944,7 @@ where OrderSide::Sell, requested_qty, reason, - format!( - "market snapshot is missing for price field {}", - price_field_name(self.execution_price_field) - ), + unavailable_reason, emit_creation_events, ); return Ok(()); @@ -3471,9 +3534,10 @@ where return Ok(()); } if data.market(date, symbol).is_none() { - self.reject_missing_market_snapshot_order( + self.reject_missing_market_or_execution_risk_order( report, date, + data, symbol, OrderSide::Sell, current_qty, @@ -3503,9 +3567,10 @@ where } let Some(snapshot) = data.market(date, symbol) else { - self.reject_missing_market_snapshot_order( + self.reject_missing_market_or_execution_risk_order( report, date, + data, symbol, if current_qty > 0 { OrderSide::Sell @@ -4030,9 +4095,10 @@ where } else { 0 }; - self.reject_missing_market_snapshot_order( + self.reject_missing_market_or_execution_risk_order( report, date, + data, symbol, if value > 0.0 { OrderSide::Buy @@ -4561,6 +4627,15 @@ where report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let Some(snapshot) = data.market(date, symbol) else { + let unavailable_reason = self + .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Buy) + .map(str::to_string) + .unwrap_or_else(|| { + format!( + "market snapshot is missing for price field {}", + price_field_name(self.execution_price_field) + ) + }); Self::reject_unavailable_order( report, date, @@ -4569,10 +4644,7 @@ where OrderSide::Buy, requested_qty, reason, - format!( - "market snapshot is missing for price field {}", - price_field_name(self.execution_price_field) - ), + unavailable_reason, emit_creation_events, ); return Ok(()); @@ -6521,6 +6593,63 @@ mod tests { ); } + #[test] + fn next_open_buy_risk_rejects_execution_date_delisted_even_without_market_snapshot() { + let signal_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(false) + .with_liquidity_limit(false); + let mut instrument = limit_test_instrument(); + instrument.delisted_at = Some(execution_date); + let data = DataSet::from_components_with_actions_and_quotes( + vec![instrument], + vec![dated_limit_test_snapshot(signal_date)], + Vec::new(), + vec![ + dated_limit_test_candidate(signal_date, false, false, true, true), + dated_limit_test_candidate(execution_date, false, false, true, true), + ], + vec![ + dated_limit_test_benchmark(signal_date), + dated_limit_test_benchmark(execution_date), + ], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(20_000.0); + + let report = broker + .execute( + execution_date, + &mut portfolio, + &data, + &next_open_buy_decision(), + ) + .expect("execute next open buy"); + + assert!(report.fill_events.is_empty()); + assert!(portfolio.position("000001.SZ").is_none()); + let rejected_order = report + .order_events + .iter() + .find(|event| event.side == OrderSide::Buy) + .expect("buy order event"); + assert_eq!(rejected_order.date, execution_date); + assert_eq!(rejected_order.status, OrderStatus::Rejected); + assert!( + rejected_order.reason.contains("inactive_or_delisted"), + "{}", + rejected_order.reason + ); + assert!( + !rejected_order.reason.contains("market snapshot is missing"), + "{}", + rejected_order.reason + ); + } + #[test] fn next_open_buy_limit_risk_uses_open_not_close() { let execution_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).expect("valid date"); diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 7f78236..b5cbc2e 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -223,6 +223,21 @@ impl ChinaAShareRiskControl { Self::instrument_rejection_reason(instrument, date) } + pub fn active_status_rejection_reason_with_config( + date: NaiveDate, + candidate: Option<&CandidateEligibility>, + instrument: Option<&Instrument>, + config: &FidcRiskControlConfig, + scope: RiskCheckScope, + ) -> Option<&'static str> { + if let Some(reason) = + Self::instrument_rejection_reason_with_config(instrument, date, config, scope) + { + return Some(reason); + } + candidate.and_then(|candidate| candidate_active_status_rejection(candidate, config, scope)) + } + pub fn selection_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility,