Files
fidc-backtest-engine/crates/fidc-core/src/risk_control.rs
T
2026-07-02 11:40:08 +08:00

989 lines
36 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_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_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_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_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 {
date,
symbol: symbol.into(),
scope: RiskCheckScope::Selection,
stage: "selection".to_string(),
accepted: false,
rule_code: reason.clone(),
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| {
FidcRiskDecisionAudit::rejected_selection(date, candidate.symbol.clone(), reason)
})
}
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 Self::missing_risk_state_rejected(candidate, config, scope) {
return Some("missing_risk_state");
}
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_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 {
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 {
return Some("kcb");
}
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");
}
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 Self::missing_risk_state_rejected(candidate, config, RiskCheckScope::Sell) {
return Some("missing_risk_state");
}
if config.static_rules.reject_paused_sell && (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 price.
// A sell order must be blocked by the execution price lower-limit check
// below, while suspension and delisting are handled above.
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 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 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_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_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_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_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" | "is_star_st" | "st" | "star_st" | "star_st_status" => match scope {
RiskCheckScope::Selection => config.static_rules.reject_st_selection,
RiskCheckScope::Buy => config.static_rules.reject_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,
RiskCheckScope::Buy => config.static_rules.reject_kcb_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_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_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_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_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_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_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"));
}
#[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 missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
let date = d(2025, 1, 2);
let mut candidate = candidate(date);
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_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_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);
}
}