diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 825fb59..5b19151 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -381,7 +381,47 @@ impl ChinaAShareRiskControl { || config.static_rules.reject_one_yuan_buy || config.static_rules.respect_allow_buy_sell } - RiskCheckScope::Sell => false, + RiskCheckScope::Sell => { + let fields = missing_risk_state_fields(code); + if fields.is_empty() { + return config.static_rules.reject_paused_sell + || config.static_rules.reject_inactive_sell + || config.static_rules.respect_allow_buy_sell + || config.static_rules.reject_lower_limit_sell; + } + (config.static_rules.reject_paused_sell + && fields.iter().any(|field| { + matches!( + field.as_str(), + "paused" | "is_paused" | "suspended" | "is_suspended" + ) + })) + || (config.static_rules.reject_inactive_sell + && fields.iter().any(|field| { + matches!( + field.as_str(), + "active_status" + | "is_active" + | "inactive_status" + | "is_inactive" + | "is_delisted" + | "listed_date" + | "delisted_date" + ) + })) + || (config.static_rules.respect_allow_buy_sell + && fields.iter().any(|field| field == "allow_sell")) + || (config.static_rules.reject_lower_limit_sell + && fields.iter().any(|field| { + matches!( + field.as_str(), + "lower_limit" + | "lower_limit_price" + | "low_limit" + | "low_limit_price" + ) + })) + } } } @@ -466,6 +506,9 @@ impl ChinaAShareRiskControl { ) { return Some(reason); } + if Self::missing_risk_state_rejected(candidate, config, RiskCheckScope::Sell) { + return Some("missing_risk_state"); + } if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) { return Some("paused"); } @@ -496,6 +539,18 @@ impl ChinaAShareRiskControl { } } +fn missing_risk_state_fields(code: &str) -> Vec { + let Some((_, fields)) = code.split_once(':') else { + return Vec::new(); + }; + fields + .split(|ch| matches!(ch, ',' | ';' | '|' | ' ')) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RiskCheckScope { @@ -644,7 +699,7 @@ mod tests { fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() { let date = d(2025, 1, 2); let mut candidate = candidate(date); - candidate.risk_level_code = Some("missing_risk_state:is_st,paused,allow_buy".to_string()); + candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string()); let market = market(date, 6.27, 5.63); let selection_reason = @@ -665,6 +720,58 @@ mod tests { assert_eq!(sell_reason, None); } + #[test] + fn missing_risk_state_rejects_sell_when_sell_risk_facts_are_missing() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.allow_sell = true; + candidate.risk_level_code = Some( + "missing_risk_state:paused,active_status,allow_sell,lower_limit_price".to_string(), + ); + let market = market(date, 6.27, 5.63); + let position = position(d(2024, 12, 31)); + + let reason = ChinaAShareRiskControl::sell_rejection_reason( + date, + &candidate, + &market, + None, + Some(&position), + 6.27, + ); + + assert_eq!(reason, Some("missing_risk_state")); + } + + #[test] + fn missing_risk_state_sell_can_be_relaxed_by_disabling_sell_static_filters() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.allow_sell = true; + candidate.risk_level_code = Some( + "missing_risk_state:paused,active_status,allow_sell,lower_limit_price".to_string(), + ); + let market = market(date, 6.27, 5.63); + let position = position(d(2024, 12, 31)); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_paused_sell = false; + config.static_rules.reject_inactive_sell = false; + config.static_rules.respect_allow_buy_sell = false; + config.static_rules.reject_lower_limit_sell = false; + + let reason = ChinaAShareRiskControl::sell_rejection_reason_with_config( + date, + &candidate, + &market, + None, + Some(&position), + 6.27, + &config, + ); + + assert_eq!(reason, None); + } + #[test] fn missing_risk_state_can_be_relaxed_by_disabling_static_filters() { let date = d(2025, 1, 2);