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