1312 lines
47 KiB
Rust
1312 lines
47 KiB
Rust
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<String>,
|
|
}
|
|
|
|
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<String>,
|
|
pub data_epoch: String,
|
|
pub selection_batch_id: Option<String>,
|
|
pub order_id: Option<String>,
|
|
}
|
|
|
|
impl FidcRiskDecisionAudit {
|
|
pub fn rejected_selection(
|
|
date: NaiveDate,
|
|
symbol: impl Into<String>,
|
|
reason: impl Into<String>,
|
|
) -> 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<String>,
|
|
rule_code: impl Into<String>,
|
|
reason: impl Into<String>,
|
|
) -> 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<FidcRiskDecisionAudit> {
|
|
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<String> {
|
|
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);
|
|
}
|
|
}
|