From 7c867f1788120bc4be1be306304fa16d8b0de84b Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 6 Jul 2026 08:05:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=86=E9=92=9F=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E9=87=8F=E9=99=90=E5=88=B6=E6=92=AE=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 81 +++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) 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)