修复风控缺字段审计优先级
This commit is contained in:
@@ -122,6 +122,17 @@ impl FidcRiskDecisionAudit {
|
|||||||
symbol: impl Into<String>,
|
symbol: impl Into<String>,
|
||||||
reason: impl Into<String>,
|
reason: impl Into<String>,
|
||||||
) -> Self {
|
) -> 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();
|
let reason = reason.into();
|
||||||
Self {
|
Self {
|
||||||
date,
|
date,
|
||||||
@@ -129,7 +140,7 @@ impl FidcRiskDecisionAudit {
|
|||||||
scope: RiskCheckScope::Selection,
|
scope: RiskCheckScope::Selection,
|
||||||
stage: "selection".to_string(),
|
stage: "selection".to_string(),
|
||||||
accepted: false,
|
accepted: false,
|
||||||
rule_code: reason.clone(),
|
rule_code,
|
||||||
reason,
|
reason,
|
||||||
config_version: Some("inline_risk_policy".to_string()),
|
config_version: Some("inline_risk_policy".to_string()),
|
||||||
data_epoch: date.to_string(),
|
data_epoch: date.to_string(),
|
||||||
@@ -264,7 +275,22 @@ impl ChinaAShareRiskControl {
|
|||||||
) -> Option<FidcRiskDecisionAudit> {
|
) -> Option<FidcRiskDecisionAudit> {
|
||||||
Self::selection_rejection_reason_with_config(date, candidate, market, instrument, config)
|
Self::selection_rejection_reason_with_config(date, candidate, market, instrument, config)
|
||||||
.map(|reason| {
|
.map(|reason| {
|
||||||
FidcRiskDecisionAudit::rejected_selection(date, candidate.symbol.clone(), 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,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,9 +326,6 @@ impl ChinaAShareRiskControl {
|
|||||||
{
|
{
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
|
||||||
return Some("missing_risk_state");
|
|
||||||
}
|
|
||||||
let reject_paused = match scope {
|
let reject_paused = match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_paused_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_paused_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_paused_buy,
|
RiskCheckScope::Buy => config.static_rules.reject_paused_buy,
|
||||||
@@ -332,7 +355,7 @@ impl ChinaAShareRiskControl {
|
|||||||
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
RiskCheckScope::Buy => config.static_rules.reject_kcb_buy,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
};
|
};
|
||||||
if reject_kcb && candidate.is_kcb {
|
if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) {
|
||||||
return Some("kcb");
|
return Some("kcb");
|
||||||
}
|
}
|
||||||
let reject_one_yuan = match scope {
|
let reject_one_yuan = match scope {
|
||||||
@@ -343,6 +366,9 @@ impl ChinaAShareRiskControl {
|
|||||||
if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) {
|
if reject_one_yuan && (candidate.is_one_yuan || market.day_open <= 1.0) {
|
||||||
return Some("one_yuan");
|
return Some("one_yuan");
|
||||||
}
|
}
|
||||||
|
if Self::missing_risk_state_rejected(candidate, config, scope) {
|
||||||
|
return Some("missing_risk_state");
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,9 +476,6 @@ impl ChinaAShareRiskControl {
|
|||||||
) {
|
) {
|
||||||
return Some(reason);
|
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) {
|
if config.static_rules.reject_paused_sell && (market.paused || candidate.is_paused) {
|
||||||
return Some("paused");
|
return Some("paused");
|
||||||
}
|
}
|
||||||
@@ -465,6 +488,9 @@ impl ChinaAShareRiskControl {
|
|||||||
{
|
{
|
||||||
return Some("open at or below lower limit");
|
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) {
|
if position.is_some_and(|position| position.sellable_qty(date) == 0) {
|
||||||
return Some("t+1 sellable quantity is zero");
|
return Some("t+1 sellable quantity is zero");
|
||||||
}
|
}
|
||||||
@@ -483,6 +509,11 @@ impl ChinaAShareRiskControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 missing_risk_state_fields(code: &str) -> Vec<String> {
|
fn missing_risk_state_fields(code: &str) -> Vec<String> {
|
||||||
let Some((_, fields)) = code.split_once(':') else {
|
let Some((_, fields)) = code.split_once(':') else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -782,6 +813,27 @@ mod tests {
|
|||||||
assert_eq!(configured_reason, None);
|
assert_eq!(configured_reason, 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]
|
#[test]
|
||||||
fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
|
fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
@@ -807,6 +859,30 @@ mod tests {
|
|||||||
assert_eq!(sell_reason, None);
|
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]
|
#[test]
|
||||||
fn missing_risk_state_rejects_sell_when_sell_risk_facts_are_missing() {
|
fn missing_risk_state_rejects_sell_when_sell_risk_facts_are_missing() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
|
|||||||
Reference in New Issue
Block a user