use chrono::NaiveDate; use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::instrument::Instrument; use crate::portfolio::Position; #[derive(Debug, Clone, Copy, Default)] pub struct ChinaAShareRiskControl; impl ChinaAShareRiskControl { 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 selection_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, ) -> Option<&'static str> { if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) { return Some(reason); } if !candidate.allow_buy || !candidate.allow_sell { return Some("trade_disabled"); } None } pub fn baseline_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, ) -> Option<&'static str> { if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { return Some(reason); } if market.paused || candidate.is_paused { return Some("paused"); } if candidate.is_st { return Some("st"); } if candidate.is_new_listing { return Some("new_listing"); } if candidate.is_kcb { return Some("kcb"); } if candidate.is_one_yuan || market.day_open <= 1.0 { return Some("one_yuan"); } None } pub fn buy_rejection_reason( date: NaiveDate, candidate: &CandidateEligibility, market: &DailyMarketSnapshot, instrument: Option<&Instrument>, check_price: f64, ) -> Option<&'static str> { if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) { return Some(reason); } if !candidate.allow_buy { return Some("buy_disabled"); } if 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> { if let Some(reason) = Self::instrument_rejection_reason(instrument, date) { return Some(reason); } if market.paused || candidate.is_paused { return Some("paused"); } // `allow_sell` is derived from the daily candidate snapshot and may // reflect an open/close fallback rather than the actual execution tick. // A sell order must be blocked by the execution price lower-limit check // below, while suspension and delisting are handled above. if market.is_at_lower_limit_price(check_price) { return Some("open at or below lower limit"); } 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), } } } #[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_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, tick_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_uses_execution_price_not_stale_allow_sell() { 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, 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 candidate = candidate(date); 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")); } }