diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c972b34..74ac5ce 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -231,6 +231,7 @@ pub struct PlatformExprStrategyConfig { pub daily_top_up_enabled: bool, pub daily_position_target_adjust_enabled: bool, pub rebalance_existing_positions: bool, + pub selection_buffer_multiple: f64, pub retry_empty_rebalance: bool, pub calendar_rebalance_interval: bool, pub max_holding_days: Option, @@ -303,6 +304,7 @@ fn band_low(index_close) { daily_top_up_enabled: false, daily_position_target_adjust_enabled: true, rebalance_existing_positions: false, + selection_buffer_multiple: 1.0, retry_empty_rebalance: false, calendar_rebalance_interval: false, max_holding_days: None, @@ -673,6 +675,43 @@ impl PlatformExprStrategy { value } + fn selection_buffer_rank(&self, selection_limit: usize) -> usize { + let multiple = if self.config.selection_buffer_multiple.is_finite() { + self.config.selection_buffer_multiple.max(1.0) + } else { + 1.0 + }; + selection_limit.max((selection_limit as f64 * multiple) as usize) + } + + fn buffered_selection( + ranked_symbols: &[String], + held_symbols: &BTreeSet, + selection_limit: usize, + buffer_rank: usize, + ) -> Vec { + if selection_limit == 0 { + return Vec::new(); + } + let mut selected = ranked_symbols + .iter() + .take(buffer_rank.max(selection_limit)) + .filter(|symbol| held_symbols.contains(*symbol)) + .cloned() + .take(selection_limit) + .collect::>(); + let mut selected_set = selected.iter().cloned().collect::>(); + for symbol in ranked_symbols { + if selected.len() >= selection_limit { + break; + } + if selected_set.insert(symbol.clone()) { + selected.push(symbol.clone()); + } + } + selected + } + fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode { if self.config.matching_type == MatchingType::MinuteLast { RebalanceCashMode::SellThenBuy @@ -8151,6 +8190,7 @@ impl PlatformExprStrategy { let selection_limit = self .selection_limit(ctx, &day)? .min(self.config.max_positions.max(1)); + let quote_selection_limit = self.selection_buffer_rank(selection_limit); let (candidate_symbols, order_symbols, processed_scope, diagnostics) = self .select_quote_plan_symbols( ctx, @@ -8160,7 +8200,7 @@ impl PlatformExprStrategy { &day, band_low, band_high, - selection_limit, + quote_selection_limit, )?; Ok(PlatformSelectionQuotePlan { execution_date: ctx.execution_date, @@ -8466,7 +8506,8 @@ impl Strategy for PlatformExprStrategy { }; let mut risk_decisions = Vec::new(); let stock_list = if self.config.rotation_enabled && !in_skip_window { - let (stock_list, notes, selection_risk_decisions) = self.select_symbols( + let selection_buffer_rank = self.selection_buffer_rank(selection_limit); + let (ranked_stock_list, notes, selection_risk_decisions) = self.select_symbols( ctx, selection_market_date, selection_universe_factor_date, @@ -8474,11 +8515,22 @@ impl Strategy for PlatformExprStrategy { &day, band_low, band_high, - selection_limit, + selection_buffer_rank, )?; selection_notes = notes; risk_decisions = selection_risk_decisions; - stock_list + let held_symbols = ctx + .portfolio + .positions() + .keys() + .cloned() + .collect::>(); + Self::buffered_selection( + &ranked_stock_list, + &held_symbols, + selection_limit, + selection_buffer_rank, + ) } else { Vec::new() }; @@ -29660,4 +29712,22 @@ mod tests { vec!["000001.SZ".to_string(), "000003.SZ".to_string()] ); } + + #[test] + fn selection_buffer_retains_held_symbols_within_buffer_rank() { + let ranked = (1..=8) + .map(|index| format!("{index:06}.SZ")) + .collect::>(); + let held = ["000005.SZ", "000006.SZ"] + .into_iter() + .map(str::to_string) + .collect::>(); + + let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6); + + assert_eq!( + selected, + vec!["000005.SZ", "000006.SZ", "000001.SZ", "000002.SZ"] + ); + } } diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 2fbd6d1..9e945a9 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -695,6 +695,8 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub rebalance_existing_positions: Option, #[serde(default)] + pub selection_buffer_multiple: Option, + #[serde(default)] pub retry_empty_rebalance: Option, #[serde(default)] pub weak_market_shrink_overweight_threshold: Option, @@ -1638,6 +1640,12 @@ pub fn platform_expr_config_from_spec( if let Some(enabled) = trading.rebalance_existing_positions { cfg.rebalance_existing_positions = enabled; } + if let Some(multiple) = trading + .selection_buffer_multiple + .filter(|value| value.is_finite() && *value >= 1.0) + { + cfg.selection_buffer_multiple = multiple; + } if let Some(enabled) = trading.retry_empty_rebalance { cfg.retry_empty_rebalance = enabled; } @@ -2957,6 +2965,7 @@ mod tests { "dailyTopUp": false, "dailyPositionTargetAdjust": false, "rebalanceExistingPositions": true, + "selectionBufferMultiple": 1.5, "retryEmptyRebalance": false } } @@ -2967,6 +2976,7 @@ mod tests { assert!(!cfg.daily_top_up_enabled); assert!(!cfg.daily_position_target_adjust_enabled); assert!(cfg.rebalance_existing_positions); + assert_eq!(cfg.selection_buffer_multiple, 1.5); assert!(!cfg.retry_empty_rebalance); }