From 7e0877b5860d8724da1c4507a1d1ba393b3497f5 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 11 Sep 2026 10:21:46 +0800 Subject: [PATCH] fix: validate price risk on every execution leg before and after slippage --- crates/fidc-core/src/broker.rs | 61 +++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 69c082d..685ee93 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -7318,9 +7318,15 @@ where execution_price: f64, ) -> Option<&'static str> { if !execution_price.is_finite() || execution_price <= 0.0 { - return None; + return Some("invalid execution price"); } match side { + OrderSide::Buy + if self.risk_config.static_rules.reject_one_yuan_buy + && execution_price <= 1.0 => + { + Some("one_yuan") + } OrderSide::Buy if self.risk_config.static_rules.reject_upper_limit_buy && snapshot.is_at_upper_limit_price(execution_price) => @@ -7623,6 +7629,11 @@ where else { continue; }; + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, raw_quote_price) { + execution_block_reason.get_or_insert(reason); + execution_block_timestamp = Some(quote.timestamp); + continue; + } let mark_price = self.quote_mark_price(quote, raw_quote_price); let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { @@ -8596,6 +8607,54 @@ mod tests { assert_eq!(fill.quantity, 1_200); } + #[test] + fn each_execution_leg_rechecks_one_yuan_including_slippage_and_limit_price() { + let mut snapshot = limit_test_snapshot(); + snapshot.open = 1.2; + snapshot.last_price = 1.2; + snapshot.upper_limit = 2.0; + snapshot.lower_limit = 0.5; + let date = snapshot.date; + let start = date.and_hms_opt(10, 0, 0).unwrap(); + let end = date.and_hms_opt(10, 2, 0).unwrap(); + let mut cheap = limit_test_quote(0.9, 0.9, 0.9); + cheap.timestamp = date.and_hms_opt(10, 1, 0).unwrap(); + let mut later = limit_test_quote(1.2, 1.2, 1.2); + later.timestamp = end; + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(false).with_liquidity_limit(false); + + let fill = broker.select_execution_fill( + &snapshot, &[cheap.clone(), later], OrderSide::Buy, MatchingType::Vwap, + Some(start), Some(end), 100, 100, 100, 100, false, None, None, None, + ).unwrap(); + assert_eq!(fill.quantity, 100); + assert_eq!(fill.legs.len(), 1); + assert_eq!(fill.legs[0].execution_timestamp, Some(end)); + assert_eq!(fill.legs[0].price, 1.2); + + let slipped = broker.with_slippage_model(SlippageModel::PriceRatio(0.2)); + let blocked = slipped.select_execution_fill( + &snapshot, &[cheap], OrderSide::Buy, MatchingType::Vwap, + Some(start), Some(end), 100, 100, 100, 100, false, None, None, None, + ).unwrap(); + assert_eq!(blocked.quantity, 0); + assert_eq!(blocked.unfilled_reason, Some("one_yuan")); + assert_eq!(slipped.execution_price_with_limit_slippage_or_rejection(&snapshot, OrderSide::Buy, 1.0, None), Err("one_yuan")); + + let limit_broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_slippage_model(SlippageModel::LimitPrice); + assert_eq!(limit_broker.execution_price_with_limit_slippage_or_rejection( + &snapshot, OrderSide::Buy, 1.2, Some(0.9)), Err("one_yuan")); + let mut risk = FidcRiskControlConfig::default(); + risk.static_rules.reject_one_yuan_buy = false; + let allowed = limit_broker.with_risk_config(risk); + assert_eq!(allowed.execution_price_with_limit_slippage_or_rejection( + &snapshot, OrderSide::Buy, 1.2, Some(0.9)), Ok(0.9)); + assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Buy, f64::NAN), Some("invalid execution price")); + assert_eq!(allowed.execution_limit_rejection_reason(&snapshot, OrderSide::Sell, 0.9), None); + } + #[test] fn minute_last_uses_volume_delta_when_level1_depth_missing() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");