diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9cf73b3..5164a08 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2919,6 +2919,7 @@ where let order_step_size = self.order_step_size(data, symbol); let price = self.sizing_price(snapshot); let snapshot_requested_qty = self.value_buy_quantity( + date, value.abs(), price, minimum_order_quantity, @@ -3014,6 +3015,7 @@ where let order_step_size = self.order_step_size(data, symbol); let price = self.sizing_price(snapshot); let snapshot_requested_qty = self.value_buy_quantity( + date, value.abs(), price, minimum_order_quantity, @@ -3181,6 +3183,7 @@ where let order_step_size = self.order_step_size(data, symbol); let price = self.sizing_price(snapshot); let snapshot_requested_qty = self.value_buy_quantity( + date, value.abs(), price, minimum_order_quantity, @@ -4066,6 +4069,7 @@ where fn value_buy_quantity( &self, + date: NaiveDate, value_budget: f64, price: f64, minimum_order_quantity: u32, @@ -4075,13 +4079,17 @@ where return 0; } let minimum = minimum_order_quantity.max(1); - let step = order_step_size.max(1); - if price * minimum as f64 > value_budget + 1e-6 { - return 0; + let raw_quantity = (value_budget / price).floor() as u32; + let mut quantity = + self.round_buy_quantity(raw_quantity, minimum_order_quantity, order_step_size); + while quantity >= minimum { + if self.estimated_buy_cash_out(date, price, quantity) <= value_budget + 1e-6 { + return quantity; + } + quantity = + self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } - let raw_steps = (value_budget / price / step as f64).round(); - let requested = ((raw_steps.max(1.0) as u32) * step).max(minimum); - self.round_buy_quantity(requested, minimum_order_quantity, order_step_size) + 0 } fn decrement_order_quantity( @@ -4364,13 +4372,15 @@ where return None; } + let quote_quantity_limited = self.quote_quantity_limited(matching_type); let lot = round_lot.max(1); let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes .iter() .filter(|quote| { !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) - && self.quote_has_executable_liquidity(quote, side, matching_type) + && (!quote_quantity_limited + || self.quote_has_executable_liquidity(quote, side, matching_type)) }) .collect(); let mut filled_qty = 0_u32; @@ -4398,21 +4408,26 @@ where continue; } let quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); - let top_level_liquidity = match side { - OrderSide::Buy => quote.ask1_volume, - OrderSide::Sell => quote.bid1_volume, - }; - let available_qty = top_level_liquidity - .saturating_mul(lot as u64) - .min(u32::MAX as u64) as u32; - if available_qty == 0 { - continue; - } let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; } + let available_qty = if quote_quantity_limited { + let top_level_liquidity = match side { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + }; + top_level_liquidity + .saturating_mul(lot as u64) + .min(u32::MAX as u64) as u32 + } else { + remaining_qty + }; + if available_qty == 0 { + continue; + } + let mut take_qty = if matching_type == MatchingType::Twap { let remaining_quotes = (eligible_quotes.len() - quote_index) as u32; let scheduled_qty = @@ -4514,6 +4529,10 @@ where } } + fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool { + self.volume_limit || self.liquidity_limit || matching_type != MatchingType::NextTickLast + } + fn uses_serial_execution_cursor(&self, reason: &str) -> bool { let _ = reason; false @@ -4579,3 +4598,35 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { "rebalance_sell" } } + +#[cfg(test)] +mod tests { + use super::{BrokerSimulator, MatchingType}; + use crate::cost::ChinaAShareCostModel; + use crate::rules::ChinaEquityRuleHooks; + + #[test] + fn next_tick_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(false) + .with_liquidity_limit(false); + + assert!(!broker.quote_quantity_limited(MatchingType::NextTickLast)); + assert!(broker.quote_quantity_limited(MatchingType::CounterpartyOffer)); + } + + #[test] + fn next_tick_last_keeps_quote_quantity_cap_when_limits_enabled() { + let volume_limited = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(true) + .with_liquidity_limit(false); + let liquidity_limited = + BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(false) + .with_liquidity_limit(true); + + assert!(volume_limited.quote_quantity_limited(MatchingType::NextTickLast)); + assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextTickLast)); + } +} diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs index 174c899..036ac25 100644 --- a/crates/fidc-core/src/cost.rs +++ b/crates/fidc-core/src/cost.rs @@ -44,7 +44,7 @@ pub struct ChinaAShareCostModel { impl Default for ChinaAShareCostModel { fn default() -> Self { Self { - commission_rate: 0.0003, + commission_rate: 0.0008, stamp_tax_rate_before_change: 0.001, stamp_tax_rate_after_change: 0.0005, minimum_commission: 5.0, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 82ed18f..6e57f8d 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -822,13 +822,18 @@ impl PlatformExprStrategy { return 0; } let minimum = minimum_order_quantity.max(1); - let step = order_step_size.max(1); - if price * minimum as f64 > value_budget + 1e-6 { - return 0; + let raw_quantity = (value_budget / price).floor() as u32; + let mut quantity = + self.round_lot_quantity(raw_quantity, minimum_order_quantity, order_step_size); + while quantity >= minimum { + let gross_amount = price * quantity as f64; + if gross_amount + self.buy_commission(gross_amount) <= value_budget + 1e-6 { + return quantity; + } + quantity = + self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size); } - let raw_steps = (value_budget / price / step as f64).round(); - let requested = ((raw_steps.max(1.0) as u32) * step).max(minimum); - self.round_lot_quantity(requested, minimum_order_quantity, order_step_size) + 0 } fn decrement_order_quantity( @@ -5093,6 +5098,16 @@ mod tests { assert!(!rewritten.contains('?')); } + #[test] + fn platform_order_value_quantity_includes_buy_commission_budget() { + let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + + assert_eq!(strategy.value_buy_quantity(4_000.0, 20.38, 100, 100), 100); + assert_eq!(strategy.value_buy_quantity(4_000.0, 15.20, 100, 100), 200); + assert_eq!(strategy.value_buy_quantity(4_000.0, 28.85, 100, 100), 100); + assert_eq!(strategy.value_buy_quantity(4_000.0, 37.40, 100, 100), 100); + } + fn sample_calendar() -> TradingCalendar { TradingCalendar::new(vec![ d(2025, 1, 30), diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs index fdee5f2..c1df7a9 100644 --- a/crates/fidc-core/tests/core_rules.rs +++ b/crates/fidc-core/tests/core_rules.rs @@ -61,7 +61,7 @@ fn china_cost_model_applies_minimum_commission_and_stamp_tax() { assert_eq!(buy.stamp_tax, 0.0); let sell = model.calculate(d(2023, 8, 25), OrderSide::Sell, 100_000.0); - assert!((sell.commission - 30.0).abs() < 1e-9); + assert!((sell.commission - 80.0).abs() < 1e-9); assert!((sell.stamp_tax - 100.0).abs() < 1e-9); } @@ -112,7 +112,7 @@ fn china_cost_model_tracks_minimum_commission_per_order_id() { assert!((first.commission - 5.0).abs() < 1e-9); assert!(second.commission.abs() < 1e-9); - assert!((third.commission - 1.6).abs() < 1e-9); + assert!((third.commission - 12.6).abs() < 1e-9); assert!((another_order.commission - 5.0).abs() < 1e-9); }