From 3a66c90f3462022783102f695b5621fe086ec8a6 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 4 Jul 2026 03:27:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=89=A7=E8=A1=8C=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E9=A3=8E=E6=8E=A7=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/engine.rs | 144 ++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index e491feb..5603f5a 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -18,7 +18,7 @@ use crate::futures::{ }; use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; -use crate::risk_control::FidcRiskDecisionAudit; +use crate::risk_control::{FidcRiskDecisionAudit, RiskCheckScope}; use crate::rules::EquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time}; use crate::strategy::{ @@ -1749,7 +1749,9 @@ where let day_orders = report.order_events.clone(); let day_fills = report.fill_events.clone(); let broker_diagnostics = report.diagnostics.clone(); + let execution_risk_decisions = risk_decisions_from_order_events(&day_orders); self.extend_result(&mut result, report); + result.risk_decisions.extend(execution_risk_decisions); let benchmark = self.data @@ -2747,8 +2749,10 @@ where let day_orders = report.order_events.clone(); let day_fills = report.fill_events.clone(); let broker_diagnostics = report.diagnostics.clone(); + let execution_risk_decisions = risk_decisions_from_order_events(&day_orders); self.extend_result(&mut result, report); result.risk_decisions.extend(decision.risk_decisions); + result.risk_decisions.extend(execution_risk_decisions); let benchmark = self.data @@ -3823,6 +3827,123 @@ fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExe target.diagnostics.extend(incoming.diagnostics); } +fn risk_decisions_from_order_events(order_events: &[OrderEvent]) -> Vec { + order_events + .iter() + .filter_map(risk_decision_from_order_event) + .collect() +} + +fn risk_decision_from_order_event(order_event: &OrderEvent) -> Option { + 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::>(); + 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( target: &mut FuturesExecutionReport, incoming: FuturesExecutionReport, @@ -3948,7 +4069,7 @@ mod tests { }; use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; - use crate::risk_control::FidcRiskControlConfig; + use crate::risk_control::{FidcRiskControlConfig, RiskCheckScope}; use crate::rules::ChinaEquityRuleHooks; use crate::scheduler::{ScheduleRule, ScheduleStage}; 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"); + 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]