实现FIDC配置化风控与交易成本
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user