diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index ef4e34b..ebda0de 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -5589,7 +5589,7 @@ where if remaining_qty == 0 { break; } - let available_qty = if quote_quantity_limited { + let mut available_qty = if quote_quantity_limited { let top_level_liquidity = match side { OrderSide::Buy => quote.ask1_volume, OrderSide::Sell => quote.bid1_volume, @@ -5600,6 +5600,15 @@ where } else { remaining_qty }; + if self.volume_limit { + let raw_limit = ((quote.volume_delta as f64) * self.volume_percent).floor() as u32; + let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + raw_limit + } else { + self.round_buy_quantity(raw_limit, minimum_order_quantity, order_step_size) + }; + available_qty = available_qty.min(volume_limited); + } if available_qty == 0 { continue; } @@ -6076,6 +6085,76 @@ mod tests { assert!(liquidity_limited.quote_quantity_limited(MatchingType::MinuteLast)); } + #[test] + fn minute_last_volume_limit_caps_quote_volume_delta_without_liquidity_limit() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_volume_limit(true) + .with_volume_percent(0.25) + .with_liquidity_limit(false); + let mut quote = limit_test_quote(10.0, 9.99, 10.01); + quote.timestamp = date.and_hms_opt(10, 15, 0).expect("valid timestamp"); + quote.volume_delta = 4_900; + quote.ask1_volume = 0; + quote.bid1_volume = 0; + + let fill = broker + .select_execution_fill( + &limit_test_snapshot(), + &[quote], + OrderSide::Buy, + MatchingType::MinuteLast, + Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), + None, + 3_200, + 100, + 100, + 100, + false, + None, + None, + None, + ) + .expect("minute last fill"); + + assert_eq!(fill.quantity, 1_200); + } + + #[test] + fn minute_last_volume_limit_rejects_sub_lot_quote_volume() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::MinuteLast) + .with_volume_limit(true) + .with_volume_percent(0.25) + .with_liquidity_limit(false); + let mut quote = limit_test_quote(10.0, 9.99, 10.01); + quote.timestamp = date.and_hms_opt(10, 15, 0).expect("valid timestamp"); + quote.volume_delta = 100; + quote.ask1_volume = 10_000; + quote.bid1_volume = 10_000; + + let fill = broker.select_execution_fill( + &limit_test_snapshot(), + &[quote], + OrderSide::Buy, + MatchingType::MinuteLast, + Some(date.and_hms_opt(10, 15, 0).expect("valid timestamp")), + None, + 3_200, + 100, + 100, + 100, + false, + None, + None, + None, + ); + + assert!(fill.is_none()); + } + #[test] fn daily_matching_does_not_require_intraday_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)