补充执行阶段风控审计

This commit is contained in:
boris
2026-07-04 03:27:24 +08:00
parent 5481db63df
commit 3a66c90f34
+142 -2
View File
@@ -18,7 +18,7 @@ use crate::futures::{
}; };
use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
use crate::risk_control::FidcRiskDecisionAudit; use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope};
use crate::rules::EquityRuleHooks; use crate::rules::EquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time}; use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
use crate::strategy::{ use crate::strategy::{
@@ -1749,7 +1749,9 @@ where
let day_orders = report.order_events.clone(); let day_orders = report.order_events.clone();
let day_fills = report.fill_events.clone(); let day_fills = report.fill_events.clone();
let broker_diagnostics = report.diagnostics.clone(); let broker_diagnostics = report.diagnostics.clone();
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
self.extend_result(&mut result, report); self.extend_result(&mut result, report);
result.risk_decisions.extend(execution_risk_decisions);
let benchmark = let benchmark =
self.data self.data
@@ -2747,8 +2749,10 @@ where
let day_orders = report.order_events.clone(); let day_orders = report.order_events.clone();
let day_fills = report.fill_events.clone(); let day_fills = report.fill_events.clone();
let broker_diagnostics = report.diagnostics.clone(); let broker_diagnostics = report.diagnostics.clone();
let execution_risk_decisions = risk_decisions_from_order_events(&day_orders);
self.extend_result(&mut result, report); self.extend_result(&mut result, report);
result.risk_decisions.extend(decision.risk_decisions); result.risk_decisions.extend(decision.risk_decisions);
result.risk_decisions.extend(execution_risk_decisions);
let benchmark = let benchmark =
self.data self.data
@@ -3823,6 +3827,123 @@ fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExe
target.diagnostics.extend(incoming.diagnostics); target.diagnostics.extend(incoming.diagnostics);
} }
fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec<FidcRiskDecisionAudit> {
order_events
.iter()
.filter_map(risk_decision_from_order_event)
.collect()
}
fn risk_decision_from_order_event(order_event: &OrderEvent) -> Option<FidcRiskDecisionAudit> {
if !matches!(
order_event.status,
OrderStatus::Canceled | OrderStatus::Rejected
) {
return None;
}
let rule_code = execution_risk_rule_code(&order_event.reason)?;
Some(FidcRiskDecisionAudit {
date: order_event.date,
symbol: order_event.symbol.clone(),
scope: match order_event.side {
OrderSide::Buy => RiskCheckScope::Buy,
OrderSide::Sell => RiskCheckScope::Sell,
},
stage: "execution".to_string(),
accepted: false,
rule_code: rule_code.to_string(),
reason: order_event.reason.clone(),
config_version: Some("inline_risk_policy".to_string()),
data_epoch: order_event.date.to_string(),
selection_batch_id: None,
order_id: order_event.order_id.map(|id| id.to_string()),
})
}
fn execution_risk_rule_code(reason: &str) -> Option<&'static str> {
let normalized = normalize_risk_reason(reason);
let tokens = normalized
.split('_')
.filter(|token| !token.is_empty())
.collect::<BTreeSet<_>>();
if normalized.contains("same_day_rebuy") || normalized.contains("sell_then_rebuy") {
return Some("same_day_rebuy");
}
if normalized.contains("blacklist") {
return Some("blacklisted");
}
if normalized.contains("star_st") || normalized.contains("st_star") {
return Some("star_st");
}
if tokens.contains("st") {
return Some("st");
}
if normalized.contains("upper_limit")
|| normalized.contains("limit_up")
|| normalized.contains("above_upper")
{
return Some("upper_limit");
}
if normalized.contains("lower_limit")
|| normalized.contains("limit_down")
|| normalized.contains("below_lower")
{
return Some("lower_limit");
}
if normalized.contains("paused")
|| normalized.contains("suspended")
|| normalized.contains("suspension")
{
return Some("paused");
}
if normalized.contains("inactive")
|| normalized.contains("delisted")
|| normalized.contains("not_listed")
{
return Some("inactive_or_delisted");
}
if tokens.contains("kcb") || normalized.contains("science_technology_board") {
return Some("kcb");
}
if tokens.contains("bjse") || normalized.contains("beijing_stock_exchange") {
return Some("bjse");
}
if normalized.contains("one_yuan") {
return Some("one_yuan");
}
if normalized.contains("trade_disabled")
|| normalized.contains("allow_buy")
|| normalized.contains("allow_sell")
{
return Some("trade_disabled");
}
if normalized.contains("no_volume") || normalized.contains("volume_limit") {
return Some("volume_limit");
}
if normalized.contains("liquidity") || normalized.contains("level1") {
return Some("liquidity_limit");
}
if normalized.contains("no_market_data")
|| normalized.contains("market_data_unavailable")
|| normalized.contains("no_executable_price")
|| normalized.contains("missing_price")
{
return Some("market_data_unavailable");
}
if normalized.contains("sellable_quantity") || normalized.contains("no_sellable_quantity") {
return Some("sellable_quantity");
}
None
}
fn normalize_risk_reason(reason: &str) -> String {
reason
.to_ascii_lowercase()
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
.collect()
}
fn merge_futures_execution_report( fn merge_futures_execution_report(
target: &mut FuturesExecutionReport, target: &mut FuturesExecutionReport,
incoming: FuturesExecutionReport, incoming: FuturesExecutionReport,
@@ -3948,7 +4069,7 @@ mod tests {
}; };
use crate::events::{OrderSide, OrderStatus}; use crate::events::{OrderSide, OrderStatus};
use crate::instrument::Instrument; use crate::instrument::Instrument;
use crate::risk_control::FidcRiskControlConfig; use crate::risk_control::{FidcRiskControlConfig, RiskCheckScope};
use crate::rules::ChinaEquityRuleHooks; use crate::rules::ChinaEquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage}; use crate::scheduler::{ScheduleRule, ScheduleStage};
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
@@ -4645,6 +4766,25 @@ mod tests {
)); ));
assert_next_open_canceled_with_reason(&result, "open at or above upper limit"); assert_next_open_canceled_with_reason(&result, "open at or above upper limit");
let execution_decision = result
.risk_decisions
.iter()
.find(|decision| {
decision.date == second
&& decision.symbol == SYMBOL
&& decision.scope == RiskCheckScope::Buy
&& decision.stage == "execution"
&& decision.rule_code == "upper_limit"
&& !decision.accepted
})
.expect("execution-day upper limit buy rejection should be audited");
assert!(
execution_decision
.reason
.contains("open at or above upper limit"),
"{}",
execution_decision.reason
);
} }
#[test] #[test]