234 lines
7.1 KiB
Rust
234 lines
7.1 KiB
Rust
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"));
|
|
}
|
|
}
|