diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 41370bf..e02d90f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -5038,12 +5038,33 @@ where return Ok(0); } - if self.inactive_limit && snapshot.minute_volume == 0 { - return Err("minute no volume".to_string()); + let uses_intraday_quantity = self.matching_type_uses_intraday_quotes(); + let available_market_volume = if uses_intraday_quantity { + snapshot.minute_volume + } else { + snapshot.volume + }; + let no_volume_reason = if uses_intraday_quantity { + "minute no volume" + } else { + "daily no volume" + }; + let volume_limit_reason = if uses_intraday_quantity { + "minute volume limit" + } else { + "daily volume limit" + }; + + if self.inactive_limit && (snapshot.paused || available_market_volume == 0) { + return Err(if snapshot.paused { + "paused".to_string() + } else { + no_volume_reason.to_string() + }); } let mut max_fill = requested_qty; - if self.liquidity_limit && !self.is_open_auction_matching() { + if self.liquidity_limit && uses_intraday_quantity && !self.is_open_auction_matching() { let top_level_liquidity = match side { OrderSide::Buy => snapshot.liquidity_for_buy(), OrderSide::Sell => snapshot.liquidity_for_sell(), @@ -5065,10 +5086,10 @@ where } if self.volume_limit { - let raw_limit = ((snapshot.minute_volume as f64) * self.volume_percent).round() as i64 + let raw_limit = ((available_market_volume as f64) * self.volume_percent).round() as i64 - consumed_turnover as i64; if raw_limit <= 0 { - return Err("minute volume limit".to_string()); + return Err(volume_limit_reason.to_string()); } let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { raw_limit as u32 @@ -5076,7 +5097,7 @@ where self.round_buy_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) }; if volume_limited == 0 { - return Err("minute volume limit".to_string()); + return Err(volume_limit_reason.to_string()); } max_fill = max_fill.min(volume_limited); } @@ -5528,8 +5549,28 @@ where } } + fn matching_type_uses_intraday_quotes(&self) -> bool { + matches!( + self.matching_type, + MatchingType::MinuteLast + | MatchingType::MinuteBestOwn + | MatchingType::MinuteBestCounterparty + | MatchingType::Vwap + | MatchingType::Twap + ) + } + fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool { - self.volume_limit || self.liquidity_limit || matching_type != MatchingType::MinuteLast + match matching_type { + MatchingType::OpenAuction + | MatchingType::CurrentBarClose + | MatchingType::NextBarOpen => false, + MatchingType::MinuteLast => self.volume_limit || self.liquidity_limit, + MatchingType::MinuteBestOwn + | MatchingType::MinuteBestCounterparty + | MatchingType::Vwap + | MatchingType::Twap => true, + } } fn quote_quantity_limited_for_window( @@ -5750,6 +5791,61 @@ mod tests { assert!(liquidity_limited.quote_quantity_limited(MatchingType::MinuteLast)); } + #[test] + fn daily_matching_does_not_require_intraday_quote_quantity() { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(true) + .with_liquidity_limit(true); + + assert!(!broker.quote_quantity_limited(MatchingType::OpenAuction)); + assert!(!broker.quote_quantity_limited(MatchingType::CurrentBarClose)); + assert!(!broker.quote_quantity_limited(MatchingType::NextBarOpen)); + assert!(broker.quote_quantity_limited(MatchingType::MinuteLast)); + assert!(broker.quote_quantity_limited(MatchingType::MinuteBestCounterparty)); + } + + #[test] + fn current_bar_close_volume_limit_uses_daily_volume_when_minute_volume_missing() { + let mut snapshot = limit_test_snapshot(); + snapshot.minute_volume = 0; + snapshot.volume = 1_000_000; + snapshot.bid1_volume = 0; + snapshot.ask1_volume = 0; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::CurrentBarClose) + .with_volume_limit(true) + .with_liquidity_limit(true); + + let fillable = + broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); + + assert_eq!(fillable, Ok(5_000)); + } + + #[test] + fn current_bar_close_volume_limit_rejects_daily_zero_volume() { + let mut snapshot = limit_test_snapshot(); + snapshot.minute_volume = 0; + snapshot.volume = 0; + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Close, + ) + .with_matching_type(MatchingType::CurrentBarClose) + .with_volume_limit(true) + .with_liquidity_limit(false); + + let fillable = + broker.market_fillable_quantity(&snapshot, OrderSide::Buy, 5_000, 100, 100, 0, false); + + assert_eq!(fillable, Err("daily no volume".to_string())); + } + #[test] fn target_value_valuation_uses_daily_snapshot_but_value_order_sizing_uses_intraday_minute() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");