实现FIDC配置化风控与交易成本

This commit is contained in:
boris
2026-07-02 07:16:47 +08:00
parent 754fc91376
commit 7db0e8da1d
17 changed files with 2689 additions and 249 deletions
+434 -14
View File
@@ -1,4 +1,7 @@
use std::collections::BTreeSet;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
use crate::instrument::Instrument;
@@ -7,7 +10,155 @@ 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,
@@ -36,43 +187,157 @@ impl ChinaAShareRiskControl {
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> {
if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) {
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 !candidate.allow_buy || !candidate.allow_sell {
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> {
if let Some(reason) = Self::instrument_rejection_reason(instrument, date) {
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 market.paused || candidate.is_paused {
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");
}
if candidate.is_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 {
return Some("st");
}
if candidate.is_new_listing {
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");
}
if candidate.is_kcb {
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");
}
if candidate.is_one_yuan || market.day_open <= 1.0 {
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
@@ -85,13 +350,39 @@ impl ChinaAShareRiskControl {
instrument: Option<&Instrument>,
check_price: f64,
) -> Option<&'static str> {
if let Some(reason) = Self::baseline_rejection_reason(date, candidate, market, instrument) {
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 !candidate.allow_buy {
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
return Some("buy_disabled");
}
if market.is_at_upper_limit_price(check_price) {
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
@@ -105,17 +396,44 @@ impl ChinaAShareRiskControl {
position: Option<&Position>,
check_price: f64,
) -> Option<&'static str> {
if let Some(reason) = Self::instrument_rejection_reason(instrument, date) {
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 market.paused || candidate.is_paused {
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 market.is_at_lower_limit_price(check_price) {
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) {
@@ -136,6 +454,14 @@ impl ChinaAShareRiskControl {
}
}
#[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::*;
@@ -230,4 +556,98 @@ mod tests {
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 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);
}
}