补充执行阶段风控审计
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user