fix: preserve authoritative STAR market classification in risk checks

This commit is contained in:
boris
2026-09-08 22:42:33 +08:00
parent 326438aac9
commit 078839b0f3
3 changed files with 51 additions and 25 deletions
+24 -1
View File
@@ -1,6 +1,17 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn listed_sector_is_kcb(value: &str) -> Option<bool> {
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instrument { pub struct Instrument {
pub symbol: String, pub symbol: String,
@@ -70,7 +81,19 @@ fn default_status() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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 { fn instrument(board: &str, round_lot: u32) -> Instrument {
Instrument { Instrument {
+7 -18
View File
@@ -3085,8 +3085,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String {
} }
fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
let has_suffix = symbol.trim().rsplit_once('.').is_some(); if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) {
if has_suffix && symbol_is_kcb(symbol) {
return "KSH".to_string(); return "KSH".to_string();
} }
let normalized = raw_board 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('.') { if let Some((_, suffix)) = symbol.rsplit_once('.') {
return suffix.to_ascii_uppercase(); return suffix.to_ascii_uppercase();
} }
if symbol_is_kcb(symbol) {
return "KSH".to_string();
}
if symbol.starts_with('8') || symbol.starts_with('4') { if symbol.starts_with('8') || symbol.starts_with('4') {
return "BJ".to_string(); return "BJ".to_string();
} }
@@ -3120,14 +3116,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String {
"UNK".to_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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -3159,14 +3147,15 @@ mod tests {
} }
#[test] #[test]
fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { fn normalize_board_does_not_infer_kcb_from_security_code() {
assert_eq!(normalize_board("688001.SH", None), "KSH"); assert_eq!(normalize_board("688001.SH", None), "SH");
assert_eq!(normalize_board("689001.SH", None), "KSH"); assert_eq!(normalize_board("689001.SH", None), "SH");
assert_eq!(normalize_board("688001.BJ", None), "BJ"); assert_eq!(normalize_board("688001.BJ", None), "BJ");
assert_eq!(normalize_board("689001.SZ", None), "SZ"); 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", 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] #[test]
+20 -6
View File
@@ -397,7 +397,7 @@ impl ChinaAShareRiskControl {
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
RiskCheckScope::Sell => false, RiskCheckScope::Sell => false,
}; };
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) { if reject_kcb && candidate.is_kcb {
return Some("kcb"); return Some("kcb");
} }
let reject_bjse = match scope { 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 { fn symbol_is_bjse(symbol: &str) -> bool {
let normalized = symbol.trim().to_ascii_uppercase(); let normalized = symbol.trim().to_ascii_uppercase();
normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE") normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE")
@@ -1009,6 +1004,24 @@ mod tests {
assert_eq!(configured_reason, None); 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] #[test]
fn st_and_star_st_filters_are_independent() { fn st_and_star_st_filters_are_independent() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
@@ -1139,6 +1152,7 @@ mod tests {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut candidate = candidate(date); let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string(); candidate.symbol = "688506.SH".to_string();
candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string()); candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63); let market = market(date, 6.27, 5.63);
let mut config = FidcRiskControlConfig::default(); let mut config = FidcRiskControlConfig::default();