use std::collections::BTreeSet; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::instrument::Instrument; use crate::portfolio::Position; #[derive(Debug, Clone, Copy, Default)] pub struct ChinaAShareRiskControl; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct StaticRiskRuleConfig { pub reject_st_selection: bool, pub reject_st_buy: bool, pub reject_star_st_selection: bool, pub reject_star_st_buy: bool, pub reject_paused_selection: bool, pub reject_paused_buy: bool, pub reject_paused_sell: bool, pub reject_inactive_selection: bool, pub reject_inactive_buy: bool, pub reject_inactive_sell: bool, pub reject_new_listing_selection: bool, pub reject_new_listing_buy: bool, pub reject_kcb_selection: bool, pub reject_kcb_buy: bool, pub reject_bjse_selection: bool, pub reject_bjse_buy: bool, pub reject_one_yuan_selection: bool, pub reject_one_yuan_buy: bool, pub respect_allow_buy_sell: bool, pub reject_upper_limit_selection: bool, pub reject_lower_limit_selection: bool, pub reject_upper_limit_buy: bool, pub reject_lower_limit_sell: bool, pub forbid_same_day_rebuy_after_sell: bool, pub blacklist_enabled: bool, #[serde(default)] pub blacklisted_symbols: BTreeSet, } impl Default for StaticRiskRuleConfig { fn default() -> Self { Self { reject_st_selection: true, reject_st_buy: true, reject_star_st_selection: true, reject_star_st_buy: true, reject_paused_selection: true, reject_paused_buy: true, reject_paused_sell: true, reject_inactive_selection: true, reject_inactive_buy: true, reject_inactive_sell: true, reject_new_listing_selection: true, reject_new_listing_buy: true, reject_kcb_selection: true, reject_kcb_buy: true, reject_bjse_selection: true, reject_bjse_buy: true, reject_one_yuan_selection: true, reject_one_yuan_buy: true, respect_allow_buy_sell: true, reject_upper_limit_selection: true, reject_lower_limit_selection: true, reject_upper_limit_buy: true, reject_lower_limit_sell: true, forbid_same_day_rebuy_after_sell: true, blacklist_enabled: true, blacklisted_symbols: BTreeSet::new(), } } } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct TradingConstraintConfig { pub volume_limit_enabled: bool, pub volume_percent: f64, pub liquidity_limit_enabled: bool, pub commission_rate: f64, pub minimum_commission: f64, pub stamp_tax_rate_before_change: f64, pub stamp_tax_rate_after_change: f64, pub stamp_tax_change_date: NaiveDate, } impl Default for TradingConstraintConfig { fn default() -> Self { Self { volume_limit_enabled: true, volume_percent: 0.25, liquidity_limit_enabled: true, commission_rate: 0.0003, minimum_commission: 5.0, stamp_tax_rate_before_change: 0.001, stamp_tax_rate_after_change: 0.0005, stamp_tax_change_date: NaiveDate::from_ymd_opt(2023, 8, 28) .expect("valid stamp tax change date"), } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct FidcRiskControlConfig { pub static_rules: StaticRiskRuleConfig, pub trading_constraints: TradingConstraintConfig, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FidcRiskDecisionAudit { pub date: NaiveDate, pub symbol: String, pub scope: RiskCheckScope, pub stage: String, pub accepted: bool, pub rule_code: String, pub reason: String, pub config_version: Option, pub data_epoch: String, pub selection_batch_id: Option, pub order_id: Option, } impl FidcRiskDecisionAudit { pub fn rejected_selection( date: NaiveDate, symbol: impl Into, reason: impl Into, ) -> Self { let reason = reason.into(); Self::rejected_selection_with_rule(date, symbol, reason.clone(), reason) } pub fn rejected_selection_with_rule( date: NaiveDate, symbol: impl Into, rule_code: impl Into, reason: impl Into, ) -> Self { let rule_code = rule_code.into(); let reason = reason.into(); Self { date, symbol: symbol.into(), scope: RiskCheckScope::Selection, stage: "selection".to_string(), accepted: false, rule_code, reason, config_version: Some("inline_risk_policy".to_string()), data_epoch: date.to_string(), selection_batch_id: Some(format!("selection:{date}")), order_id: None, } } pub fn diagnostic_line(&self) -> String { match serde_json::to_string(self) { Ok(value) => format!("risk_decision={value}"), Err(err) => format!( "risk_decision_serialize_error symbol={} date={} error={}", self.symbol, self.date, err ), } } } impl ChinaAShareRiskControl { pub fn default_config() -> FidcRiskControlConfig { FidcRiskControlConfig::default() } fn is_blacklisted(config: &FidcRiskControlConfig, symbol: &str) -> bool { config.static_rules.blacklist_enabled && config.static_rules.blacklisted_symbols.contains(symbol) } pub fn instrument_rejection_reason( instrument: Option<&Instrument>, date: NaiveDate, ) -> Option<&'static str> { let instrument = instrument?; if instrument .listed_at .is_some_and(|listed_at| listed_at > date) { return Some("not_listed"); } if instrument .delisted_at .is_some_and(|delisted_at| delisted_at <= date) { return Some("inactive_or_delisted"); } let status = instrument.status.trim().to_ascii_lowercase(); let terminal_status = matches!( status.as_str(), "inactive" | "delisted" | "terminated" | "expired" ) || status.contains("delist"); if terminal_status && instrument.delisted_at.is_none() { return Some("inactive_or_delisted"); } None } pub fn instrument_rejection_reason_with_config( instrument: Option<&Instrument>, date: NaiveDate, config: &FidcRiskControlConfig, scope: RiskCheckScope, ) -> Option<&'static str> { let enabled = match scope { RiskCheckScope::Selection => config.static_rules.reject_inactive_selection, RiskCheckScope::Buy => config.static_rules.reject_inactive_buy, RiskCheckScope::Sell => config.static_rules.reject_inactive_sell, }; if !enabled { return None; } Self::instrument_rejection_reason(instrument, date) } pub fn selection_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, ) -> Option<&'static str> { Self::selection_rejection_reason_with_config( date, candidate, market, instrument, &Self::default_config(), ) } pub fn selection_rejection_reason_with_config( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, config: &FidcRiskControlConfig, ) -> Option<&'static str> { if let Some(reason) = Self::baseline_rejection_reason_with_config( date, candidate, market, instrument, config, RiskCheckScope::Selection, ) { return Some(reason); } if config.static_rules.respect_allow_buy_sell && (!candidate.allow_buy || !candidate.allow_sell) { return Some("trade_disabled"); } let selection_price = market.price(PriceField::Last); if config.static_rules.reject_upper_limit_selection && market.is_at_upper_limit_price(selection_price) { return Some("upper_limit"); } if config.static_rules.reject_lower_limit_selection && market.is_at_lower_limit_price(selection_price) { return Some("lower_limit"); } None } pub fn selection_rejection_decision_with_config( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, config: &FidcRiskControlConfig, ) -> Option { Self::selection_rejection_reason_with_config(date, candidate, market, instrument, config) .map(|reason| { let detail = if reason == "missing_risk_state" { candidate .risk_level_code .as_deref() .map(str::trim) .filter(|value| value.starts_with("missing_risk_state")) .unwrap_or(reason) } else { reason }; FidcRiskDecisionAudit::rejected_selection_with_rule( date, candidate.symbol.clone(), reason, detail, ) }) } pub fn baseline_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, ) -> Option<&'static str> { Self::baseline_rejection_reason_with_config( date, candidate, market, instrument, &Self::default_config(), RiskCheckScope::Selection, ) } pub fn baseline_rejection_reason_with_config( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, config: &FidcRiskControlConfig, scope: RiskCheckScope, ) -> Option<&'static str> { if Self::is_blacklisted(config, &candidate.symbol) && scope != RiskCheckScope::Sell { return Some("blacklisted"); } if let Some(reason) = Self::instrument_rejection_reason_with_config(instrument, date, config, scope) { return Some(reason); } if let Some(reason) = candidate_active_status_rejection(candidate, config, scope) { return Some(reason); } let reject_paused = match scope { RiskCheckScope::Selection => config.static_rules.reject_paused_selection, RiskCheckScope::Buy => config.static_rules.reject_paused_buy, RiskCheckScope::Sell => config.static_rules.reject_paused_sell, }; if reject_paused && (market.paused || candidate.is_paused) { return Some("paused"); } let reject_star_st = match scope { RiskCheckScope::Selection => config.static_rules.reject_star_st_selection, RiskCheckScope::Buy => config.static_rules.reject_star_st_buy, RiskCheckScope::Sell => false, }; if reject_star_st && candidate.is_star_st { return Some("star_st"); } let reject_st = match scope { RiskCheckScope::Selection => config.static_rules.reject_st_selection, RiskCheckScope::Buy => config.static_rules.reject_st_buy, RiskCheckScope::Sell => false, }; if reject_st && candidate.is_st && !candidate.is_star_st { return Some("st"); } let reject_new_listing = match scope { RiskCheckScope::Selection => config.static_rules.reject_new_listing_selection, RiskCheckScope::Buy => config.static_rules.reject_new_listing_buy, RiskCheckScope::Sell => false, }; if reject_new_listing && candidate.is_new_listing { return Some("new_listing"); } let reject_kcb = match scope { RiskCheckScope::Selection => config.static_rules.reject_kcb_selection, RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, RiskCheckScope::Sell => false, }; if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) { return Some("kcb"); } let reject_bjse = match scope { RiskCheckScope::Selection => config.static_rules.reject_bjse_selection, RiskCheckScope::Buy => config.static_rules.reject_bjse_buy, RiskCheckScope::Sell => false, }; if reject_bjse && symbol_is_bjse(&candidate.symbol) { return Some("bjse"); } let reject_one_yuan = match scope { RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection, RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy, RiskCheckScope::Sell => false, }; if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) { return Some("one_yuan"); } if Self::missing_risk_state_rejected(candidate, config, scope) { return Some("missing_risk_state"); } None } fn missing_risk_state_rejected( candidate: &CandidateEligibility, config: &FidcRiskControlConfig, scope: RiskCheckScope, ) -> bool { let Some(code) = candidate .risk_level_code .as_deref() .map(str::trim) .filter(|value| value.starts_with("missing_risk_state")) else { return false; }; if code.is_empty() { return false; } match scope { RiskCheckScope::Selection => missing_selection_risk_state_rejected(code, config), RiskCheckScope::Buy => missing_buy_risk_state_rejected(code, config), RiskCheckScope::Sell => missing_sell_risk_state_rejected(code, config), } } pub fn buy_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, check_price: f64, ) -> Option<&'static str> { Self::buy_rejection_reason_with_config( date, candidate, market, instrument, check_price, &Self::default_config(), ) } pub fn buy_rejection_reason_with_config( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, check_price: f64, config: &FidcRiskControlConfig, ) -> Option<&'static str> { if let Some(reason) = Self::baseline_rejection_reason_with_config( date, candidate, market, instrument, config, RiskCheckScope::Buy, ) { return Some(reason); } if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy { return Some("buy_disabled"); } if config.static_rules.reject_upper_limit_buy && market.is_at_upper_limit_price(check_price) { return Some("open at or above upper limit"); } None } pub fn sell_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, position: Option<&Position>, check_price: f64, ) -> Option<&'static str> { Self::sell_rejection_reason_with_config( date, candidate, market, instrument, position, check_price, &Self::default_config(), ) } pub fn sell_rejection_reason_with_config( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, position: Option<&Position>, check_price: f64, config: &FidcRiskControlConfig, ) -> Option<&'static str> { if let Some(reason) = Self::instrument_rejection_reason_with_config( instrument, date, config, RiskCheckScope::Sell, ) { return Some(reason); } if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) { return Some("paused"); } if config.static_rules.respect_allow_buy_sell && !candidate.allow_sell { return Some("sell_disabled"); } if config.static_rules.reject_lower_limit_sell && market.is_at_lower_limit_price(check_price) { return Some("open at or below lower limit"); } if Self::missing_risk_state_rejected(candidate, config, RiskCheckScope::Sell) { return Some("missing_risk_state"); } if position.is_some_and(|position| position.sellable_qty(date) == 0) { return Some("t+1 sellable quantity is zero"); } None } pub fn buy_check_price(market: &DailyMarketSnapshot, price_field: PriceField) -> f64 { market.buy_price(price_field) } pub fn sell_check_price(market: &DailyMarketSnapshot, price_field: PriceField) -> f64 { match price_field { PriceField::Last => market.price(PriceField::Last), _ => market.sell_price(price_field), } } } 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") } fn candidate_active_status_rejection( candidate: &CandidateEligibility, config: &FidcRiskControlConfig, scope: RiskCheckScope, ) -> Option<&'static str> { let enabled = match scope { RiskCheckScope::Selection => config.static_rules.reject_inactive_selection, RiskCheckScope::Buy => config.static_rules.reject_inactive_buy, RiskCheckScope::Sell => config.static_rules.reject_inactive_sell, }; if !enabled { return None; } match candidate.risk_level_code.as_deref().map(str::trim) { Some("not_listed") => Some("not_listed"), Some("inactive_or_delisted") => Some("inactive_or_delisted"), _ => None, } } fn missing_risk_state_fields(code: &str) -> Vec { let Some((_, fields)) = code.split_once(':') else { return Vec::new(); }; fields .split(|ch| matches!(ch, ',' | ';' | '|' | ' ')) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.to_ascii_lowercase()) .collect() } fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool { let fields = missing_risk_state_fields(code); if fields.is_empty() { return config.static_rules.reject_st_selection || config.static_rules.reject_star_st_selection || config.static_rules.reject_paused_selection || config.static_rules.reject_inactive_selection || config.static_rules.reject_new_listing_selection || config.static_rules.reject_kcb_selection || config.static_rules.reject_bjse_selection || config.static_rules.reject_one_yuan_selection || config.static_rules.reject_upper_limit_selection || config.static_rules.reject_lower_limit_selection || config.static_rules.respect_allow_buy_sell; } missing_field_rejected(&fields, config, RiskCheckScope::Selection) } fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool { let fields = missing_risk_state_fields(code); if fields.is_empty() { return config.static_rules.reject_st_buy || config.static_rules.reject_star_st_buy || config.static_rules.reject_paused_buy || config.static_rules.reject_inactive_buy || config.static_rules.reject_new_listing_buy || config.static_rules.reject_kcb_buy || config.static_rules.reject_bjse_buy || config.static_rules.reject_one_yuan_buy || config.static_rules.reject_upper_limit_buy || config.static_rules.respect_allow_buy_sell; } missing_field_rejected(&fields, config, RiskCheckScope::Buy) } fn missing_sell_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool { let fields = missing_risk_state_fields(code); if fields.is_empty() { return config.static_rules.reject_paused_sell || config.static_rules.reject_inactive_sell || config.static_rules.respect_allow_buy_sell || config.static_rules.reject_lower_limit_sell; } missing_field_rejected(&fields, config, RiskCheckScope::Sell) } fn missing_field_rejected( fields: &[String], config: &FidcRiskControlConfig, scope: RiskCheckScope, ) -> bool { fields .iter() .any(|field| missing_single_field_rejected(field, config, scope)) } fn missing_single_field_rejected( field: &str, config: &FidcRiskControlConfig, scope: RiskCheckScope, ) -> bool { match field { "is_st" | "st" => match scope { RiskCheckScope::Selection => config.static_rules.reject_st_selection, RiskCheckScope::Buy => config.static_rules.reject_st_buy, RiskCheckScope::Sell => false, }, "is_star_st" | "star_st" | "star_st_status" => match scope { RiskCheckScope::Selection => config.static_rules.reject_star_st_selection, RiskCheckScope::Buy => config.static_rules.reject_star_st_buy, RiskCheckScope::Sell => false, }, "paused" | "is_paused" | "suspended" | "is_suspended" => match scope { RiskCheckScope::Selection => config.static_rules.reject_paused_selection, RiskCheckScope::Buy => config.static_rules.reject_paused_buy, RiskCheckScope::Sell => config.static_rules.reject_paused_sell, }, "active_status" | "status" | "instrument_status" | "is_active" | "inactive_status" | "is_inactive" | "is_delisted" | "listed_date" | "delisted_date" => match scope { RiskCheckScope::Selection => config.static_rules.reject_inactive_selection, RiskCheckScope::Buy => config.static_rules.reject_inactive_buy, RiskCheckScope::Sell => config.static_rules.reject_inactive_sell, }, "listed_days" | "listing_days" | "days_since_listing" => match scope { RiskCheckScope::Selection => config.static_rules.reject_new_listing_selection, RiskCheckScope::Buy => config.static_rules.reject_new_listing_buy, RiskCheckScope::Sell => false, }, "is_kcb" | "kcb" | "board" | "market_board" => match scope { RiskCheckScope::Selection => { config.static_rules.reject_kcb_selection || config.static_rules.reject_bjse_selection } RiskCheckScope::Buy => { config.static_rules.reject_kcb_buy || config.static_rules.reject_bjse_buy } RiskCheckScope::Sell => false, }, "is_bjse" | "bjse" | "bse" | "beijing_exchange" | "north_exchange" => match scope { RiskCheckScope::Selection => config.static_rules.reject_bjse_selection, RiskCheckScope::Buy => config.static_rules.reject_bjse_buy, RiskCheckScope::Sell => false, }, "is_one_yuan" | "one_yuan" => match scope { RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection, RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy, RiskCheckScope::Sell => false, }, "allow_buy" => match scope { RiskCheckScope::Selection | RiskCheckScope::Buy => { config.static_rules.respect_allow_buy_sell } RiskCheckScope::Sell => false, }, "allow_sell" => match scope { RiskCheckScope::Selection | RiskCheckScope::Sell => { config.static_rules.respect_allow_buy_sell } RiskCheckScope::Buy => false, }, "upper_limit" | "upper_limit_price" | "high_limit" | "high_limit_price" => match scope { RiskCheckScope::Selection => config.static_rules.reject_upper_limit_selection, RiskCheckScope::Buy => config.static_rules.reject_upper_limit_buy, RiskCheckScope::Sell => false, }, "lower_limit" | "lower_limit_price" | "low_limit" | "low_limit_price" => match scope { RiskCheckScope::Selection => config.static_rules.reject_lower_limit_selection, RiskCheckScope::Buy => false, RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell, }, _ => match scope { RiskCheckScope::Selection => { config.static_rules.reject_st_selection || config.static_rules.reject_star_st_selection || config.static_rules.reject_paused_selection || config.static_rules.reject_inactive_selection || config.static_rules.reject_new_listing_selection || config.static_rules.reject_kcb_selection || config.static_rules.reject_bjse_selection || config.static_rules.reject_one_yuan_selection || config.static_rules.reject_upper_limit_selection || config.static_rules.reject_lower_limit_selection || config.static_rules.respect_allow_buy_sell } RiskCheckScope::Buy => { config.static_rules.reject_st_buy || config.static_rules.reject_star_st_buy || config.static_rules.reject_paused_buy || config.static_rules.reject_inactive_buy || config.static_rules.reject_new_listing_buy || config.static_rules.reject_kcb_buy || config.static_rules.reject_bjse_buy || config.static_rules.reject_one_yuan_buy || config.static_rules.reject_upper_limit_buy || config.static_rules.respect_allow_buy_sell } RiskCheckScope::Sell => { config.static_rules.reject_paused_sell || config.static_rules.reject_inactive_sell || config.static_rules.reject_lower_limit_sell || config.static_rules.respect_allow_buy_sell } }, } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RiskCheckScope { Selection, Buy, Sell, } #[cfg(test)] mod tests { use super::*; fn d(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } fn candidate(date: NaiveDate) -> CandidateEligibility { CandidateEligibility { date, symbol: "002633.SZ".to_string(), is_st: false, is_star_st: false, is_new_listing: false, is_paused: false, allow_buy: true, allow_sell: false, is_kcb: false, is_one_yuan: false, risk_level_code: None, } } fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot { DailyMarketSnapshot { date, symbol: "002633.SZ".to_string(), timestamp: Some(format!("{date} 10:18:00")), day_open: last_price, open: last_price, high: last_price, low: last_price, close: last_price, last_price, bid1: last_price, ask1: last_price, prev_close: 6.25, volume: 1_000_000, minute_volume: 10_000, bid1_volume: 10_000, ask1_volume: 10_000, trading_phase: Some("continuous".to_string()), paused: false, upper_limit: 6.89, lower_limit, price_tick: 0.01, } } fn position(prev_date: NaiveDate) -> Position { let mut position = Position::new("002633.SZ"); position.buy(prev_date, 7_200, 8.48); position } #[test] fn sell_rejection_respects_allow_sell_policy_on_execution_day() { let prev_date = d(2024, 4, 16); let date = d(2024, 4, 17); let candidate = candidate(date); let market = market(date, 6.27, 5.63); let position = position(prev_date); let reason = ChinaAShareRiskControl::sell_rejection_reason( date, &candidate, &market, None, Some(&position), 6.27, ); assert_eq!(reason, Some("sell_disabled")); let mut relaxed = FidcRiskControlConfig::default(); relaxed.static_rules.respect_allow_buy_sell = false; let relaxed_reason = ChinaAShareRiskControl::sell_rejection_reason_with_config( date, &candidate, &market, None, Some(&position), 6.27, &relaxed, ); assert_eq!(relaxed_reason, None); } #[test] fn sell_rejection_blocks_execution_price_at_lower_limit() { let prev_date = d(2024, 4, 16); let date = d(2024, 4, 17); let mut candidate = candidate(date); candidate.allow_sell = true; let market = market(date, 5.63, 5.63); let position = position(prev_date); let reason = ChinaAShareRiskControl::sell_rejection_reason( date, &candidate, &market, None, Some(&position), 5.63, ); assert_eq!(reason, Some("open at or below lower limit")); } #[test] fn configurable_blacklist_rejects_selection_and_buy() { let date = d(2025, 1, 2); let candidate = candidate(date); let market = market(date, 6.27, 5.63); let mut config = FidcRiskControlConfig::default(); config .static_rules .blacklisted_symbols .insert(candidate.symbol.clone()); let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); let buy_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.27, &config, ); assert_eq!(selection_reason, Some("blacklisted")); assert_eq!(buy_reason, Some("blacklisted")); } #[test] fn configurable_kcb_filter_can_be_disabled() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.is_kcb = true; let market = market(date, 6.27, 5.63); let default_reason = ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_kcb_selection = false; config.static_rules.reject_kcb_buy = false; let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.27, &config, ); assert_eq!(default_reason, Some("kcb")); assert_eq!(configured_reason, None); } #[test] fn st_and_star_st_filters_are_independent() { let date = d(2025, 1, 2); let market = market(date, 6.27, 5.63); let mut st_candidate = candidate(date); st_candidate.is_st = true; st_candidate.allow_sell = true; let mut star_st_candidate = candidate(date); star_st_candidate.is_star_st = true; star_st_candidate.allow_sell = true; let mut overlapping_star_st_candidate = candidate(date); overlapping_star_st_candidate.is_st = true; overlapping_star_st_candidate.is_star_st = true; overlapping_star_st_candidate.allow_sell = true; let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_st_selection = false; config.static_rules.reject_st_buy = false; config.static_rules.reject_star_st_selection = true; config.static_rules.reject_star_st_buy = true; assert_eq!( ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &st_candidate, &market, None, &config, ), None ); assert_eq!( ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &star_st_candidate, &market, None, &config, ), Some("star_st") ); assert_eq!( ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &overlapping_star_st_candidate, &market, None, &config, ), Some("star_st") ); config.static_rules.reject_st_selection = true; config.static_rules.reject_st_buy = true; config.static_rules.reject_star_st_selection = false; config.static_rules.reject_star_st_buy = false; assert_eq!( ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &st_candidate, &market, None, 6.27, &config, ), Some("st") ); assert_eq!( ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &star_st_candidate, &market, None, 6.27, &config, ), None ); assert_eq!( ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &overlapping_star_st_candidate, &market, None, 6.27, &config, ), None ); } #[test] fn configurable_bjse_filter_can_be_disabled() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.symbol = "430047.BJ".to_string(); candidate.allow_sell = true; let market = market(date, 6.27, 5.63); let default_selection = ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); let default_buy = ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.27); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_bjse_selection = false; config.static_rules.reject_bjse_buy = false; let configured_selection = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); let configured_buy = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.27, &config, ); assert_eq!(default_selection, Some("bjse")); assert_eq!(default_buy, Some("bjse")); assert_eq!(configured_selection, None); assert_eq!(configured_buy, None); } #[test] fn concrete_kcb_reason_wins_over_generic_missing_risk_state() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.symbol = "688506.SH".to_string(); candidate.risk_level_code = Some("missing_risk_state".to_string()); let market = market(date, 6.27, 5.63); let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config( date, &candidate, &market, None, &FidcRiskControlConfig::default(), ) .expect("kcb selection rejection"); assert_eq!(decision.rule_code, "kcb"); assert_eq!(decision.reason, "kcb"); } #[test] fn explicit_candidate_inactive_status_is_not_treated_as_missing_risk_state() { 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); assert_eq!( ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None), Some("inactive_or_delisted") ); assert_eq!( ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.27), Some("inactive_or_delisted") ); let mut relaxed = FidcRiskControlConfig::default(); relaxed.static_rules.reject_inactive_selection = false; relaxed.static_rules.reject_inactive_buy = false; assert_eq!( ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &relaxed ), None ); assert_eq!( ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.27, &relaxed ), None ); } #[test] fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string()); let market = market(date, 6.27, 5.63); let selection_reason = ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); let buy_reason = ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.27); let sell_reason = ChinaAShareRiskControl::sell_rejection_reason( date, &candidate, &market, None, Some(&position(d(2024, 12, 31))), 6.27, ); assert_eq!(selection_reason, Some("missing_risk_state")); assert_eq!(buy_reason, Some("missing_risk_state")); assert_eq!(sell_reason, None); } #[test] fn missing_risk_state_selection_audit_keeps_missing_fields_in_reason() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy,upper_limit_price".to_string()); let market = market(date, 6.27, 5.63); let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config( date, &candidate, &market, None, &FidcRiskControlConfig::default(), ) .expect("missing risk state rejection"); assert_eq!(decision.rule_code, "missing_risk_state"); assert_eq!( decision.reason, "missing_risk_state:is_st,allow_buy,upper_limit_price" ); } #[test] fn missing_risk_state_rejects_sell_when_sell_risk_facts_are_missing() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; candidate.risk_level_code = Some( "missing_risk_state:paused,active_status,allow_sell,lower_limit_price".to_string(), ); let market = market(date, 6.27, 5.63); let position = position(d(2024, 12, 31)); let reason = ChinaAShareRiskControl::sell_rejection_reason( date, &candidate, &market, None, Some(&position), 6.27, ); assert_eq!(reason, Some("missing_risk_state")); } #[test] fn missing_risk_state_sell_can_be_relaxed_by_disabling_sell_static_filters() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; candidate.risk_level_code = Some( "missing_risk_state:paused,active_status,allow_sell,lower_limit_price".to_string(), ); let market = market(date, 6.27, 5.63); let position = position(d(2024, 12, 31)); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_paused_sell = false; config.static_rules.reject_inactive_sell = false; config.static_rules.respect_allow_buy_sell = false; config.static_rules.reject_lower_limit_sell = false; let reason = ChinaAShareRiskControl::sell_rejection_reason_with_config( date, &candidate, &market, None, Some(&position), 6.27, &config, ); assert_eq!(reason, None); } #[test] fn missing_risk_state_respects_field_specific_selection_switches() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; candidate.risk_level_code = Some("missing_risk_state:upper_limit_price,lower_limit_price".to_string()); let market = market(date, 6.27, 5.63); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_upper_limit_selection = false; let upper_disabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); config.static_rules.reject_lower_limit_selection = false; let both_disabled_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); assert_eq!(upper_disabled_reason, Some("missing_risk_state")); assert_eq!(both_disabled_reason, None); } #[test] fn missing_risk_state_respects_field_specific_buy_switches() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.risk_level_code = Some("missing_risk_state:upper_limit_price,lower_limit_price".to_string()); let market = market(date, 6.27, 5.63); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_upper_limit_buy = false; let buy_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.27, &config, ); assert_eq!(buy_reason, None); } #[test] fn missing_risk_state_can_be_relaxed_by_disabling_static_filters() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; candidate.risk_level_code = Some("missing_risk_state:is_st,paused,allow_buy".to_string()); let market = market(date, 6.27, 5.63); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_st_selection = false; config.static_rules.reject_st_buy = false; config.static_rules.reject_paused_selection = false; config.static_rules.reject_paused_buy = false; config.static_rules.reject_inactive_selection = false; config.static_rules.reject_inactive_buy = false; config.static_rules.reject_new_listing_selection = false; config.static_rules.reject_new_listing_buy = false; config.static_rules.reject_kcb_selection = false; config.static_rules.reject_kcb_buy = false; config.static_rules.reject_bjse_selection = false; config.static_rules.reject_bjse_buy = false; config.static_rules.reject_one_yuan_selection = false; config.static_rules.reject_one_yuan_buy = false; config.static_rules.respect_allow_buy_sell = false; let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); let buy_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.27, &config, ); assert_eq!(selection_reason, None); assert_eq!(buy_reason, None); } #[test] fn configurable_upper_limit_buy_filter_can_be_disabled() { let date = d(2025, 1, 2); let candidate = candidate(date); let market = market(date, 6.89, 5.63); let default_reason = ChinaAShareRiskControl::buy_rejection_reason(date, &candidate, &market, None, 6.89); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_upper_limit_buy = false; let configured_reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( date, &candidate, &market, None, 6.89, &config, ); assert_eq!(default_reason, Some("open at or above upper limit")); assert_eq!(configured_reason, None); } #[test] fn configurable_upper_limit_selection_filter_can_be_disabled() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; let market = market(date, 6.89, 5.63); let default_reason = ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_upper_limit_selection = false; let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); assert_eq!(default_reason, Some("upper_limit")); assert_eq!(configured_reason, None); } #[test] fn configurable_lower_limit_selection_filter_can_be_disabled() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; let market = market(date, 5.63, 5.63); let default_reason = ChinaAShareRiskControl::selection_rejection_reason(date, &candidate, &market, None); let mut config = FidcRiskControlConfig::default(); config.static_rules.reject_lower_limit_selection = false; let configured_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( date, &candidate, &market, None, &config, ); assert_eq!(default_reason, Some("lower_limit")); assert_eq!(configured_reason, None); } }