From 078839b0f30d1a53ff454fc3b5981fcbcb1ea434 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 22:42:33 +0800 Subject: [PATCH] fix: preserve authoritative STAR market classification in risk checks --- crates/fidc-core/src/instrument.rs | 25 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 25 +++++------------- crates/fidc-core/src/risk_control.rs | 26 ++++++++++++++----- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index 454c597..381639b 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -1,6 +1,17 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +pub fn listed_sector_is_kcb(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true), + "主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板" + | "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层" + | "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE" + | "BSE" => Some(false), + _ => None, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Instrument { pub symbol: String, @@ -70,7 +81,19 @@ fn default_status() -> String { #[cfg(test)] mod tests { - use super::Instrument; + use super::{Instrument, listed_sector_is_kcb}; + + #[test] + fn listing_sector_is_explicit_and_unknown_stays_unknown() { + assert_eq!(listed_sector_is_kcb("科创板"), Some(true)); + assert_eq!(listed_sector_is_kcb(" star "), Some(true)); + assert_eq!(listed_sector_is_kcb("主板"), Some(false)); + assert_eq!(listed_sector_is_kcb("创业板"), Some(false)); + assert_eq!(listed_sector_is_kcb("北证"), Some(false)); + for value in ["", "-", "SH", "688001.SH", "半导体"] { + assert_eq!(listed_sector_is_kcb(value), None); + } + } fn instrument(board: &str, round_lot: u32) -> Instrument { Instrument { diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 00d76e2..26970e7 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -3085,8 +3085,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String { } fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { - let has_suffix = symbol.trim().rsplit_once('.').is_some(); - if has_suffix && symbol_is_kcb(symbol) { + if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) { return "KSH".to_string(); } let normalized = raw_board @@ -3101,9 +3100,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { if let Some((_, suffix)) = symbol.rsplit_once('.') { return suffix.to_ascii_uppercase(); } - if symbol_is_kcb(symbol) { - return "KSH".to_string(); - } if symbol.starts_with('8') || symbol.starts_with('4') { return "BJ".to_string(); } @@ -3120,14 +3116,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { "UNK".to_string() } -fn symbol_is_kcb(symbol: &str) -> bool { - let normalized = symbol.trim().to_ascii_uppercase(); - let Some((code, suffix)) = normalized.rsplit_once('.') else { - return normalized.starts_with("688") || normalized.starts_with("689"); - }; - suffix == "SH" && (code.starts_with("688") || code.starts_with("689")) -} - #[cfg(test)] mod tests { use super::*; @@ -3159,14 +3147,15 @@ mod tests { } #[test] - fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { - assert_eq!(normalize_board("688001.SH", None), "KSH"); - assert_eq!(normalize_board("689001.SH", None), "KSH"); + fn normalize_board_does_not_infer_kcb_from_security_code() { + assert_eq!(normalize_board("688001.SH", None), "SH"); + assert_eq!(normalize_board("689001.SH", None), "SH"); assert_eq!(normalize_board("688001.BJ", None), "BJ"); assert_eq!(normalize_board("689001.SZ", None), "SZ"); - assert_eq!(normalize_board("688001", None), "KSH"); + assert_eq!(normalize_board("688001", None), "SH"); assert_eq!(normalize_board("688001", Some("SZ")), "SZ"); - assert_eq!(normalize_board("688001.SH", Some("SH")), "KSH"); + assert_eq!(normalize_board("688001.SH", Some("SH")), "SH"); + assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH"); } #[test] diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index e701f96..cec4d24 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -397,7 +397,7 @@ impl ChinaAShareRiskControl { RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, RiskCheckScope::Sell => false, }; - if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) { + if reject_kcb && candidate.is_kcb { return Some("kcb"); } let reject_bjse = match scope { @@ -600,11 +600,6 @@ 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 symbol_is_bjse(symbol: &str) -> bool { let normalized = symbol.trim().to_ascii_uppercase(); normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE") @@ -1009,6 +1004,24 @@ mod tests { assert_eq!(configured_reason, None); } + #[test] + fn kcb_filter_uses_classification_instead_of_security_code() { + let date = d(2025, 1, 2); + let market = market(date, 6.27, 5.63); + let mut candidate = candidate(date); + let config = FidcRiskControlConfig::default(); + for symbol in ["688001.SH", "689001.SH", "000001.SZ"] { + candidate.symbol = symbol.to_string(); + for is_kcb in [false, true] { + candidate.is_kcb = is_kcb; + let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, &candidate, &market, None, 6.27, &config, + ); + assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}"); + } + } + } + #[test] fn st_and_star_st_filters_are_independent() { let date = d(2025, 1, 2); @@ -1139,6 +1152,7 @@ mod tests { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.symbol = "688506.SH".to_string(); + candidate.is_kcb = true; candidate.risk_level_code = Some("missing_risk_state".to_string()); let market = market(date, 6.27, 5.63); let mut config = FidcRiskControlConfig::default();