diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 88873d4..9dfb001 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2295,7 +2295,10 @@ where position: &crate::portfolio::Position, algo_request: Option<&AlgoExecutionRequest>, ) -> RuleCheck { - if !self.aiquant_execution_rules && !candidate.allow_sell { + if self.risk_config.static_rules.respect_allow_buy_sell + && !self.aiquant_execution_rules + && !candidate.allow_sell + { return RuleCheck::reject("sell_disabled"); } let check_price = if self.aiquant_execution_rules { diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 5603f5a..bb64c3f 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3911,10 +3911,13 @@ fn execution_risk_rule_code(reason: &str) -> Option<&'static str> { if normalized.contains("one_yuan") { return Some("one_yuan"); } - if normalized.contains("trade_disabled") - || normalized.contains("allow_buy") - || normalized.contains("allow_sell") - { + if normalized.contains("buy_disabled") || normalized.contains("allow_buy") { + return Some("buy_disabled"); + } + if normalized.contains("sell_disabled") || normalized.contains("allow_sell") { + return Some("sell_disabled"); + } + if normalized.contains("trade_disabled") { return Some("trade_disabled"); } if normalized.contains("no_volume") || normalized.contains("volume_limit") { @@ -4921,6 +4924,61 @@ mod tests { assert_round_trip_sell_canceled_with_reason(&result, "open at or below lower limit"); } + #[test] + fn next_bar_open_sell_respects_allow_sell_policy_on_execution_day() { + let first = d(2025, 1, 2); + let second = d(2025, 1, 3); + let third = d(2025, 1, 6); + let fourth = d(2025, 1, 7); + let dataset = dataset_from_market_and_candidates( + vec![ + market(first, 10.0, 10.5), + market(second, 11.0, 11.5), + market(third, 12.0, 12.5), + market(fourth, 12.0, 12.2), + ], + vec![ + candidate(first), + candidate(second), + candidate(third), + candidate_with_sell_state(fourth, false, false), + ], + ); + + let rejected = run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset.clone(), + scheduled_next_open_broker(FidcRiskControlConfig::default()), + ); + assert_round_trip_sell_canceled_with_reason(&rejected, "sell_disabled"); + let execution_decision = rejected + .risk_decisions + .iter() + .find(|decision| { + decision.date == fourth + && decision.symbol == SYMBOL + && decision.scope == RiskCheckScope::Sell + && decision.stage == "execution" + && decision.rule_code == "sell_disabled" + && !decision.accepted + }) + .expect("execution-day sell_disabled rejection should be audited"); + assert_eq!(execution_decision.order_id.as_deref(), Some("2")); + + let mut relaxed_config = FidcRiskControlConfig::default(); + relaxed_config.static_rules.respect_allow_buy_sell = false; + let relaxed = run_scheduled_round_trip_next_open_with_dataset_and_broker( + dataset, + scheduled_next_open_broker(relaxed_config), + ); + let sell_fill = relaxed + .fills + .iter() + .find(|fill| fill.side == OrderSide::Sell) + .expect("sell should execute when allow_sell policy is disabled"); + assert_eq!(sell_fill.date, fourth); + assert_eq!(sell_fill.price, 12.0); + } + #[test] fn next_bar_open_sell_volume_limit_ignores_decision_day_zero_volume() { let first = d(2025, 1, 2);