diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 801ffb6..44daf88 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -515,6 +515,13 @@ impl ChinaAShareRiskControl { ) { return Some(reason); } + // When instrument metadata is unavailable, an explicit candidate + // lifecycle fact must still protect the sell path. Otherwise a + // `inactive_or_delisted` candidate could fall through to a synthetic + // sell price and violate the unresolved-delisted holding contract. + if let Some(reason) = candidate_active_status_rejection(candidate, config, RiskCheckScope::Sell) { + return Some(reason); + } if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) { return Some("paused"); } @@ -1138,6 +1145,29 @@ mod tests { ); } + #[test] + fn sell_rejects_explicit_candidate_inactive_status_without_instrument_row() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.allow_sell = true; + candidate.risk_level_code = Some("inactive_or_delisted".to_string()); + let market = market(date, 6.27, 5.63); + let position = position(d(2024, 12, 31)); + + assert_eq!( + ChinaAShareRiskControl::sell_rejection_reason_with_config( + date, + &candidate, + &market, + None, + Some(&position), + 6.27, + &FidcRiskControlConfig::default(), + ), + Some("inactive_or_delisted") + ); + } + #[test] fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() { let date = d(2025, 1, 2);