From d2aa16a2f0064297d0d8c931060646d66422e9d4 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 02:18:22 +0800 Subject: [PATCH] perf(risk): avoid per-symbol selection checks when the frozen policy has none --- .../fidc-core/src/platform_expr_strategy.rs | 5 +- crates/fidc-core/src/risk_control.rs | 107 ++++++++++++++---- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 58eac3c..c8cd351 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -10353,6 +10353,7 @@ impl PlatformExprStrategy { ) -> (Vec, Vec) { let mut symbol_ids = Vec::new(); let mut decisions = Vec::new(); + let selection_checks_enabled = self.config.risk_config.static_rules.selection_checks_enabled(); let mut eligible_symbols = vec![false; ctx.data.symbol_count()]; let execution_day = ctx.data.daily_snapshot_view(date); let factor_day = ctx.data.daily_snapshot_view(factor_date); @@ -10398,7 +10399,9 @@ impl PlatformExprStrategy { let Some(market) = execution_day.market(symbol_id) else { continue; }; - let (reject_from_universe, selection_decision) = if collect_risk_decisions { + let (reject_from_universe, selection_decision) = if !selection_checks_enabled { + (false, None) + } else if collect_risk_decisions { let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config( date, candidate, diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index bfc159e..ac90f29 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -76,6 +76,26 @@ impl Default for StaticRiskRuleConfig { } } +impl StaticRiskRuleConfig { + pub(crate) fn selection_checks_enabled(&self) -> bool { + (self.blacklist_enabled && !self.blacklisted_symbols.is_empty()) + || self.selection_state_checks_enabled() + } + + fn selection_state_checks_enabled(&self) -> bool { + self.reject_st_selection + || self.reject_star_st_selection + || self.reject_paused_selection + || self.reject_inactive_selection + || self.reject_new_listing_selection + || self.reject_kcb_selection + || self.reject_bjse_selection + || self.reject_one_yuan_selection + || self.reject_upper_limit_selection + || self.reject_lower_limit_selection + } +} + #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct TradingConstraintConfig { /// Shared execution limits. These fields intentionally use the same @@ -654,16 +674,7 @@ fn missing_risk_state_fields(code: &str) -> Vec { fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool { let fields = missing_risk_state_fields(code); if fields.is_empty() { - return config.static_rules.reject_st_selection - || config.static_rules.reject_star_st_selection - || config.static_rules.reject_paused_selection - || config.static_rules.reject_inactive_selection - || config.static_rules.reject_new_listing_selection - || config.static_rules.reject_kcb_selection - || config.static_rules.reject_bjse_selection - || config.static_rules.reject_one_yuan_selection - || config.static_rules.reject_upper_limit_selection - || config.static_rules.reject_lower_limit_selection; + return config.static_rules.selection_state_checks_enabled(); } missing_field_rejected(&fields, config, RiskCheckScope::Selection) } @@ -778,18 +789,7 @@ fn missing_single_field_rejected( RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell, }, _ => match scope { - RiskCheckScope::Selection => { - config.static_rules.reject_st_selection - || config.static_rules.reject_star_st_selection - || config.static_rules.reject_paused_selection - || config.static_rules.reject_inactive_selection - || config.static_rules.reject_new_listing_selection - || config.static_rules.reject_kcb_selection - || config.static_rules.reject_bjse_selection - || config.static_rules.reject_one_yuan_selection - || config.static_rules.reject_upper_limit_selection - || config.static_rules.reject_lower_limit_selection - } + RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(), RiskCheckScope::Buy => { config.static_rules.reject_st_buy || config.static_rules.reject_star_st_buy @@ -914,6 +914,69 @@ mod tests { position } + #[test] + fn selection_check_activation_covers_every_configured_flag_and_blacklist_state() { + let fields = [ + "reject_st_selection", "reject_star_st_selection", "reject_paused_selection", + "reject_inactive_selection", "reject_new_listing_selection", "reject_kcb_selection", + "reject_bjse_selection", "reject_one_yuan_selection", "reject_upper_limit_selection", + "reject_lower_limit_selection", + ]; + let base = serde_json::to_value(StaticRiskRuleConfig::default()).unwrap(); + let declared = base.as_object().unwrap().keys() + .filter(|key| key.ends_with("_selection")) + .map(String::as_str).collect::>(); + assert_eq!(declared, fields.into_iter().collect()); + for mask in 0..(1_u32 << fields.len()) { + for (blacklist_enabled, populated) in [(false, false), (false, true), (true, false), (true, true)] { + let mut value = base.clone(); + for (bit, field) in fields.iter().enumerate() { + value[*field] = serde_json::json!(mask & (1 << bit) != 0); + } + value["blacklist_enabled"] = serde_json::json!(blacklist_enabled); + value["blacklisted_symbols"] = if populated { + serde_json::json!(["002633.SZ"]) + } else { serde_json::json!([]) }; + let config: StaticRiskRuleConfig = serde_json::from_value(value).unwrap(); + assert_eq!(config.selection_checks_enabled(), mask != 0 || (blacklist_enabled && populated)); + } + } + } + + #[test] + fn inactive_selection_checks_preserve_missing_facts_and_execution_rejections() { + let date = d(2025, 2, 6); + let mut candidate = candidate(date); + candidate.is_st = true; + candidate.is_star_st = true; + candidate.is_paused = true; + candidate.is_new_listing = true; + candidate.is_kcb = true; + candidate.is_one_yuan = true; + candidate.allow_buy = false; + let snapshot = market(date, 0.9, 0.9); + let config = FidcRiskControlConfig::default(); + assert!(!config.static_rules.selection_checks_enabled()); + let instrument = instrument("delisted", Some(date)); + for code in [None, Some("not_listed"), Some("inactive_or_delisted"), + Some("missing_risk_state"), Some("missing_risk_state:is_st;is_kcb|allow_buy"), + Some("missing_risk_state:unknown_fact"), Some("missing_risk_state:IS_PAUSED")] { + candidate.risk_level_code = code.map(str::to_owned); + assert_eq!(ChinaAShareRiskControl::selection_rejection_decision_with_config( + date, &candidate, &snapshot, Some(&instrument), &config), None); + } + candidate.risk_level_code = None; + assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, &candidate, &snapshot, None, 0.9, &config), Some("paused")); + assert_eq!(ChinaAShareRiskControl::sell_rejection_reason_with_config( + date, &candidate, &snapshot, None, None, 0.9, &config), Some("paused")); + let mut blacklist_only = config; + blacklist_only.static_rules.blacklisted_symbols.insert(candidate.symbol.to_string()); + assert!(blacklist_only.static_rules.selection_checks_enabled()); + assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config( + date, &candidate, &snapshot, None, &blacklist_only), Some("blacklisted")); + } + #[test] fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() { let day = d(2025, 2, 6);