fix: preserve authoritative STAR market classification in risk checks
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
use chrono::NaiveDate;
|
||||
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)]
|
||||
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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user