diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 8ab8990..6280a22 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -122,6 +122,17 @@ impl FidcRiskDecisionAudit { symbol: impl Into, reason: impl Into, ) -> Self { + let reason = reason.into(); + Self::rejected_selection_with_rule(date, symbol, reason.clone(), reason) + } + + pub fn rejected_selection_with_rule( + date: NaiveDate, + symbol: impl Into, + rule_code: impl Into, + reason: impl Into, + ) -> Self { + let rule_code = rule_code.into(); let reason = reason.into(); Self { date, @@ -129,7 +140,7 @@ impl FidcRiskDecisionAudit { scope: RiskCheckScope::Selection, stage: "selection".to_string(), accepted: false, - rule_code: reason.clone(), + rule_code, reason, config_version: Some("inline_risk_policy".to_string()), data_epoch: date.to_string(), @@ -264,7 +275,22 @@ impl ChinaAShareRiskControl { ) -> Option { Self::selection_rejection_reason_with_config(date, candidate, market, instrument, config) .map(|reason| { - FidcRiskDecisionAudit::rejected_selection(date, candidate.symbol.clone(), reason) + let detail = if reason == "missing_risk_state" { + candidate + .risk_level_code + .as_deref() + .map(str::trim) + .filter(|value| value.starts_with("missing_risk_state")) + .unwrap_or(reason) + } else { + reason + }; + FidcRiskDecisionAudit::rejected_selection_with_rule( + date, + candidate.symbol.clone(), + reason, + detail, + ) }) } @@ -300,9 +326,6 @@ impl ChinaAShareRiskControl { { return Some(reason); } - if Self::missing_risk_state_rejected(candidate, config, scope) { - return Some("missing_risk_state"); - } let reject_paused = match scope { RiskCheckScope::Selection => config.static_rules.reject_paused_selection, RiskCheckScope::Buy => config.static_rules.reject_paused_buy, @@ -332,7 +355,7 @@ impl ChinaAShareRiskControl { RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, RiskCheckScope::Sell => false, }; - if reject_kcb && candidate.is_kcb { + if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) { return Some("kcb"); } let reject_one_yuan = match scope { @@ -343,6 +366,9 @@ impl ChinaAShareRiskControl { if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) { return Some("one_yuan"); } + if Self::missing_risk_state_rejected(candidate, config, scope) { + return Some("missing_risk_state"); + } None } @@ -450,9 +476,6 @@ 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"); } @@ -465,6 +488,9 @@ impl ChinaAShareRiskControl { { return Some("open at or below lower limit"); } + if Self::missing_risk_state_rejected(candidate, config, RiskCheckScope::Sell) { + return Some("missing_risk_state"); + } if position.is_some_and(|position| position.sellable_qty(date) == 0) { return Some("t+1 sellable quantity is zero"); } @@ -483,6 +509,11 @@ impl ChinaAShareRiskControl { } } +fn symbol_is_kcb(symbol: &str) -> bool { + let normalized = symbol.trim().to_ascii_uppercase(); + (normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH") +} + fn missing_risk_state_fields(code: &str) -> Vec { let Some((_, fields)) = code.split_once(':') else { return Vec::new(); @@ -782,6 +813,27 @@ mod tests { assert_eq!(configured_reason, None); } + #[test] + fn concrete_kcb_reason_wins_over_generic_missing_risk_state() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.symbol = "688506.SH".to_string(); + candidate.risk_level_code = Some("missing_risk_state".to_string()); + let market = market(date, 6.27, 5.63); + + let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config( + date, + &candidate, + &market, + None, + &FidcRiskControlConfig::default(), + ) + .expect("kcb selection rejection"); + + assert_eq!(decision.rule_code, "kcb"); + assert_eq!(decision.reason, "kcb"); + } + #[test] fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() { let date = d(2025, 1, 2); @@ -807,6 +859,30 @@ mod tests { assert_eq!(sell_reason, None); } + #[test] + fn missing_risk_state_selection_audit_keeps_missing_fields_in_reason() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.risk_level_code = + Some("missing_risk_state:is_st,allow_buy,upper_limit_price".to_string()); + let market = market(date, 6.27, 5.63); + + let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config( + date, + &candidate, + &market, + None, + &FidcRiskControlConfig::default(), + ) + .expect("missing risk state rejection"); + + assert_eq!(decision.rule_code, "missing_risk_state"); + assert_eq!( + decision.reason, + "missing_risk_state:is_st,allow_buy,upper_limit_price" + ); + } + #[test] fn missing_risk_state_rejects_sell_when_sell_risk_facts_are_missing() { let date = d(2025, 1, 2);