From ff2844884d94dba12117fbcd4d88136c455cd1d0 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 29 Aug 2026 04:02:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=80=80=E5=B8=82=E5=80=99?= =?UTF-8?q?=E9=80=89=E5=8D=96=E5=87=BA=E9=A3=8E=E6=8E=A7=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/risk_control.rs | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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);