diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 35b1f4c..d23844f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2505,7 +2505,7 @@ where merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.legs) } else { - let mut execution_price = + let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Sell, Some(fillable_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Sell, execution_price) @@ -2524,16 +2524,26 @@ where ); (0, Vec::new()) } else { - execution_price = - self.execution_price_with_limit_slippage(execution_price, limit_price); - ( - fillable_qty, - vec![ExecutionLeg { - price: execution_price, - mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), - quantity: fillable_qty, - }], - ) + match self.execution_price_with_limit_slippage_or_rejection( + snapshot, + OrderSide::Sell, + execution_price, + limit_price, + ) { + Ok(execution_price) => ( + fillable_qty, + vec![ExecutionLeg { + price: execution_price, + mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), + quantity: fillable_qty, + }], + ), + Err(reason) => { + partial_fill_reason = + merge_partial_fill_reason(partial_fill_reason, Some(reason)); + (0, Vec::new()) + } + } } }; if filled_qty == 0 { @@ -3885,7 +3895,7 @@ where merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); (fill.quantity, fill.legs) } else { - let mut execution_price = + let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(constrained_qty)); if let Some(reason) = self.execution_limit_rejection_reason(snapshot, OrderSide::Buy, execution_price) @@ -3904,43 +3914,77 @@ where ); (0, Vec::new()) } else { - execution_price = - self.execution_price_with_limit_slippage(execution_price, limit_price); - let filled_qty = self.affordable_buy_quantity( - date, - buy_cash_limit, - value_gross_limit, + match self.execution_price_with_limit_slippage_or_rejection( + snapshot, + OrderSide::Buy, execution_price, - constrained_qty, - self.minimum_order_quantity(data, symbol), - self.order_step_size(data, symbol), - ); - if filled_qty > 0 { - execution_price = - self.snapshot_execution_price(snapshot, OrderSide::Buy, Some(filled_qty)); - execution_price = - self.execution_price_with_limit_slippage(execution_price, limit_price); - } - if filled_qty < constrained_qty { - partial_fill_reason = merge_partial_fill_reason( - partial_fill_reason, - self.buy_reduction_reason( + limit_price, + ) { + Err(reason) => { + partial_fill_reason = + merge_partial_fill_reason(partial_fill_reason, Some(reason)); + (0, Vec::new()) + } + Ok(mut execution_price) => { + let mut filled_qty = self.affordable_buy_quantity( + date, buy_cash_limit, value_gross_limit, execution_price, constrained_qty, - filled_qty, - ), - ); + self.minimum_order_quantity(data, symbol), + self.order_step_size(data, symbol), + ); + let mut blocked_by_final_price = false; + if filled_qty > 0 { + execution_price = self.snapshot_execution_price( + snapshot, + OrderSide::Buy, + Some(filled_qty), + ); + match self.execution_price_with_limit_slippage_or_rejection( + snapshot, + OrderSide::Buy, + execution_price, + limit_price, + ) { + Ok(price) => execution_price = price, + Err(reason) => { + partial_fill_reason = merge_partial_fill_reason( + partial_fill_reason, + Some(reason), + ); + filled_qty = 0; + blocked_by_final_price = true; + } + } + } + if blocked_by_final_price { + (0, Vec::new()) + } else { + if filled_qty < constrained_qty { + partial_fill_reason = merge_partial_fill_reason( + partial_fill_reason, + self.buy_reduction_reason( + buy_cash_limit, + value_gross_limit, + execution_price, + constrained_qty, + filled_qty, + ), + ); + } + ( + filled_qty, + vec![ExecutionLeg { + price: execution_price, + mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), + quantity: filled_qty, + }], + ) + } + } } - ( - filled_qty, - vec![ExecutionLeg { - price: execution_price, - mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), - quantity: filled_qty, - }], - ) } }; if filled_qty == 0 { @@ -4567,6 +4611,21 @@ where } } + fn execution_price_with_limit_slippage_or_rejection( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + execution_price: f64, + limit_price: Option, + ) -> Result { + let adjusted = self.execution_price_with_limit_slippage(execution_price, limit_price); + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, adjusted) { + Err(reason) + } else { + Ok(adjusted) + } + } + fn limit_order_can_remain_open(partial_reason: Option<&str>) -> bool { !partial_reason.is_some_and(|reason| { reason.contains("insufficient cash") @@ -4819,6 +4878,14 @@ where } quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); + if let Some(reason) = + self.execution_limit_rejection_reason(snapshot, side, quote_price) + { + execution_block_reason.get_or_insert(reason); + execution_block_timestamp = Some(quote.timestamp); + take_qty = 0; + break; + } let candidate_gross = gross_amount + quote_price * take_qty as f64; if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { budget_block_reason = Some("value budget limit"); @@ -4851,6 +4918,12 @@ where quote_price = self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty)); quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); + if let Some(reason) = self.execution_limit_rejection_reason(snapshot, side, quote_price) + { + execution_block_reason.get_or_insert(reason); + execution_block_timestamp = Some(quote.timestamp); + continue; + } gross_amount += quote_price * take_qty as f64; mark_amount += mark_price * take_qty as f64; diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index d1ed7d6..d55ea4a 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -4237,6 +4237,50 @@ fn broker_uses_limit_price_slippage_for_limit_orders() { assert!((report.fill_events[0].price - 10.1).abs() < 1e-9); } +#[test] +fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = two_day_limit_order_data(10.0, 10.2); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ) + .with_slippage_model(SlippageModel::LimitPrice); + let mut portfolio = PortfolioState::new(1_000_000.0); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::LimitShares { + symbol: "000002.SZ".to_string(), + quantity: 200, + limit_price: 11.0, + reason: "limit_entry_at_upper_limit".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert!(report.fill_events.is_empty()); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].status, OrderStatus::Canceled); + assert!( + report.order_events[0] + .reason + .contains("open at or above upper limit") + ); + assert!(portfolio.position("000002.SZ").is_none()); +} + #[test] fn broker_executes_limit_value_and_limit_percent_intents() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();