From c557656040e957c4002d42f0cfc8be89bd2b5b3d Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 5 Jul 2026 17:01:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E7=95=A5=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E6=88=90=E4=BA=A4=E9=87=8F=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 252 ++++++++++++++++-- crates/fidc-core/src/strategy.rs | 50 +++- 2 files changed, 281 insertions(+), 21 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index b9d0fe2..79243b5 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1519,6 +1519,89 @@ impl PlatformExprStrategy { } } + #[allow(clippy::too_many_arguments)] + fn projected_market_fillable_quantity( + &self, + market: &DailyMarketSnapshot, + quote: Option<&crate::data::IntradayExecutionQuote>, + symbol: &str, + side: OrderSide, + requested_qty: u32, + round_lot: u32, + minimum_order_quantity: u32, + order_step_size: u32, + allow_odd_lot_sell: bool, + current_fill_quantity: u32, + execution_state: &ProjectedExecutionState, + ) -> Option { + if requested_qty == 0 { + return Some(0); + } + + let constraints = self.config.risk_config.trading_constraints; + let mut max_fill = requested_qty; + + if self.config.quote_quantity_limit || constraints.liquidity_limit_enabled { + let top_level_liquidity = match (quote, side) { + (Some(quote), OrderSide::Buy) => quote + .ask1_volume + .saturating_mul(round_lot.max(1) as u64) + .min(u32::MAX as u64) as u32, + (Some(quote), OrderSide::Sell) => quote + .bid1_volume + .saturating_mul(round_lot.max(1) as u64) + .min(u32::MAX as u64) as u32, + (None, OrderSide::Buy) => market.liquidity_for_buy().min(u32::MAX as u64) as u32, + (None, OrderSide::Sell) => market.liquidity_for_sell().min(u32::MAX as u64) as u32, + }; + if top_level_liquidity == 0 { + return None; + } + let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + top_level_liquidity + } else { + self.round_lot_quantity( + top_level_liquidity, + minimum_order_quantity, + order_step_size, + ) + }; + if liquidity_limited == 0 { + return None; + } + max_fill = max_fill.min(liquidity_limited); + } + + if constraints.volume_limit_enabled { + if market.minute_volume == 0 { + return None; + } + let consumed_turnover = execution_state + .intraday_turnover + .get(symbol) + .copied() + .unwrap_or(0) + .saturating_add(current_fill_quantity); + let raw_limit = ((market.minute_volume as f64) * constraints.volume_percent).floor() + as i64 + - consumed_turnover as i64; + if raw_limit <= 0 { + return None; + } + let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell { + raw_limit as u32 + } else { + self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size) + }; + if volume_limited == 0 { + return None; + } + max_fill = max_fill.min(volume_limited); + } + + Some(max_fill) + } + fn projected_select_execution_fill( &self, ctx: &StrategyContext<'_>, @@ -1595,24 +1678,28 @@ impl PlatformExprStrategy { let Some(raw_quote_price) = self.projected_quote_raw_price(quote, side) else { continue; }; - let available_qty = if self.config.quote_quantity_limit { - match side { - OrderSide::Buy => quote.ask1_volume, - OrderSide::Sell => quote.bid1_volume, - } - .saturating_mul(round_lot.max(1) as u64) - .min(u32::MAX as u64) as u32 - } else { - requested_qty.saturating_sub(filled_qty) - }; - if available_qty == 0 { - continue; - } - let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; } + let available_qty = self + .projected_market_fillable_quantity( + market, + Some(quote), + symbol, + side, + remaining_qty, + round_lot, + minimum_order_quantity, + order_step_size, + allow_odd_lot_sell, + filled_qty, + execution_state, + ) + .unwrap_or(0); + if available_qty == 0 { + break; + } let mut take_qty = remaining_qty.min(available_qty); if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { take_qty = @@ -1780,9 +1867,25 @@ impl PlatformExprStrategy { execution_time, ) && ctx.data.execution_quotes_on(date, symbol).is_empty() { + let fallback_quantity = self.projected_market_fillable_quantity( + market, + None, + symbol, + OrderSide::Sell, + quantity, + round_lot, + minimum_order_quantity, + order_step_size, + sellable_qty >= current_qty, + 0, + execution_state, + )?; + if fallback_quantity == 0 { + return None; + } Some(ProjectedExecutionFill { price: self.projected_execution_price(market, OrderSide::Sell), - quantity, + quantity: fallback_quantity, next_cursor: date.and_time( execution_time.unwrap_or_else(|| self.intraday_execution_start_time()), ) + Duration::seconds(1), @@ -2144,9 +2247,25 @@ impl PlatformExprStrategy { None, ) && ctx.data.execution_quotes_on(date, symbol).is_empty() { + let fallback_quantity = self.projected_market_fillable_quantity( + ctx.data.market(date, symbol)?, + None, + symbol, + OrderSide::Buy, + quantity, + round_lot, + minimum_order_quantity, + order_step_size, + false, + 0, + execution_state, + )?; + if fallback_quantity == 0 { + return None; + } Some(ProjectedExecutionFill { price: sizing_price, - quantity, + quantity: fallback_quantity, next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1), }) @@ -9880,6 +9999,8 @@ mod tests { cfg.slippage_model = SlippageModel::PriceRatio(0.002); cfg.matching_type = MatchingType::MinuteLast; cfg.quote_quantity_limit = false; + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).expect("time")); let strategy = PlatformExprStrategy::new(cfg); let quote = ctx @@ -9910,6 +10031,92 @@ mod tests { assert!((position.average_cost - 5.12022).abs() < 1e-9); } + #[test] + fn platform_projected_market_fill_caps_volume_limit() { + let date = d(2023, 5, 12); + let symbol = "603176.SH"; + let market = DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: None, + day_open: 5.10, + open: 5.10, + high: 5.20, + low: 5.00, + close: 5.20, + last_price: 5.11, + bid1: 5.11, + ask1: 5.12, + prev_close: 5.00, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 5.50, + lower_limit: 4.50, + price_tick: 0.01, + }; + let quote = IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"), + last_price: 5.11, + bid1: 5.11, + ask1: 5.12, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 51_100.0, + trading_phase: Some("continuous".to_string()), + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.quote_quantity_limit = false; + cfg.risk_config.trading_constraints.volume_limit_enabled = true; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; + cfg.risk_config.trading_constraints.volume_percent = 0.25; + let strategy = PlatformExprStrategy::new(cfg); + let mut execution_state = super::ProjectedExecutionState::default(); + + assert_eq!( + strategy.projected_market_fillable_quantity( + &market, + Some("e), + symbol, + OrderSide::Buy, + 50_000, + 100, + 100, + 100, + false, + 0, + &execution_state, + ), + Some(2_500) + ); + + execution_state + .intraday_turnover + .insert(symbol.to_string(), 2_400); + assert_eq!( + strategy.projected_market_fillable_quantity( + &market, + Some("e), + symbol, + OrderSide::Buy, + 50_000, + 100, + 100, + 100, + false, + 0, + &execution_state, + ), + Some(100) + ); + } + #[test] fn platform_aiquant_target_value_uses_scheduled_mark_for_delta() { let prev_date = d(2023, 5, 11); @@ -10482,6 +10689,8 @@ mod tests { cfg.signal_symbol = symbol.to_string(); cfg.stop_loss_expr.clear(); cfg.take_profit_expr = "1.07".to_string(); + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; let mut strategy = PlatformExprStrategy::new(cfg); let decision = strategy.on_day(&ctx).expect("platform decision"); @@ -10691,6 +10900,8 @@ mod tests { cfg.signal_symbol = symbol.to_string(); cfg.stop_loss_expr.clear(); cfg.take_profit_expr = "1.07".to_string(); + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; let mut strategy = PlatformExprStrategy::new(cfg); let first_decision = strategy.on_day(&first_ctx).expect("first decision"); @@ -10983,6 +11194,8 @@ mod tests { cfg.delayed_limit_open_exit_enabled = true; cfg.delayed_limit_open_exit_time = Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()); cfg.skip_month_day_ranges = vec![(Some(2024), 1, 15, 31)]; + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; let mut strategy = PlatformExprStrategy::new(cfg); strategy .pending_highlimit_holdings @@ -14864,6 +15077,8 @@ mod tests { cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 40, 0).unwrap()); cfg.commission_rate = Some(0.0003); cfg.minimum_commission = Some(5.0); + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; let strategy = PlatformExprStrategy::new(cfg); let mut projected = portfolio.clone(); let mut execution_state = super::ProjectedExecutionState::default(); @@ -17217,6 +17432,9 @@ mod tests { .to_string(); cfg.exposure_expr = "signal_close > 0 ? 1.0 : 0.5".to_string(); cfg.buy_scale_expr = "touched_upper_limit ? 1.0 : 0.5".to_string(); + cfg.quote_quantity_limit = false; + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; cfg.rank_expr = concat!( "(close / rolling_mean(\"close\", 2)) * 0.5", " + pct_change(\"close\", 1) * boost" @@ -20291,6 +20509,8 @@ mod tests { cfg.aiquant_transaction_cost = true; cfg.strict_value_budget = true; cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()); + cfg.risk_config.trading_constraints.volume_limit_enabled = false; + cfg.risk_config.trading_constraints.liquidity_limit_enabled = false; let mut strategy = PlatformExprStrategy::new(cfg); strategy.rebalance_day_counter = 20; diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index b8e81af..85e3a12 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -2034,7 +2034,6 @@ impl OmniMicroCapStrategy { Some(fill.quantity) } - #[allow(dead_code)] fn projected_market_fillable_quantity( &self, ctx: &StrategyContext<'_>, @@ -2046,18 +2045,19 @@ impl OmniMicroCapStrategy { minimum_order_quantity: u32, order_step_size: u32, allow_odd_lot_sell: bool, + current_fill_quantity: u32, execution_state: &ProjectedExecutionState, ) -> Option { if requested_qty == 0 { return Some(0); } let snapshot = ctx.data.market(date, symbol)?; - if snapshot.minute_volume == 0 { + let constraints = self.config.risk_config.trading_constraints; + if constraints.volume_limit_enabled && snapshot.minute_volume == 0 { return None; } let mut max_fill = requested_qty; - let constraints = self.config.risk_config.trading_constraints; if constraints.liquidity_limit_enabled { let top_level_liquidity = match side { OrderSide::Buy => snapshot.liquidity_for_buy(), @@ -2079,7 +2079,12 @@ impl OmniMicroCapStrategy { max_fill = max_fill.min(liquidity_limited); } - let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0); + let consumed_turnover = execution_state + .intraday_turnover + .get(symbol) + .copied() + .unwrap_or(0) + .saturating_add(current_fill_quantity); if constraints.volume_limit_enabled { let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor() as i64 @@ -2129,6 +2134,23 @@ impl OmniMicroCapStrategy { return None; } + let requested_qty = self.projected_market_fillable_quantity( + ctx, + date, + symbol, + side, + requested_qty, + round_lot, + minimum_order_quantity, + order_step_size, + allow_odd_lot_sell, + 0, + execution_state, + )?; + if requested_qty == 0 { + return None; + } + if let Some(market) = ctx.data.market(date, symbol) { let execution_price = self.projected_execution_price(market, side); if execution_price.is_finite() && execution_price > 0.0 { @@ -2222,7 +2244,25 @@ impl OmniMicroCapStrategy { if remaining_qty == 0 { break; } - let mut take_qty = remaining_qty.min(available_qty); + let market_fillable_qty = self + .projected_market_fillable_quantity( + ctx, + date, + symbol, + side, + remaining_qty, + round_lot, + minimum_order_quantity, + order_step_size, + allow_odd_lot_sell, + filled_qty, + execution_state, + ) + .unwrap_or(0); + if market_fillable_qty == 0 { + break; + } + let mut take_qty = remaining_qty.min(available_qty).min(market_fillable_qty); if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) { take_qty = self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size);