diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 064e9b6..458d584 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -361,6 +361,7 @@ pub struct PlatformExprStrategyConfig { pub daily_position_target_adjust_enabled: bool, pub rebalance_existing_positions: bool, pub selection_buffer_multiple: f64, + pub daily_replacement_limit: usize, pub retry_empty_rebalance: bool, pub calendar_rebalance_interval: bool, pub max_holding_days: Option, @@ -435,6 +436,7 @@ fn band_low(index_close) { daily_position_target_adjust_enabled: true, rebalance_existing_positions: false, selection_buffer_multiple: 1.0, + daily_replacement_limit: 0, retry_empty_rebalance: false, calendar_rebalance_interval: false, max_holding_days: None, @@ -829,6 +831,7 @@ impl PlatformExprStrategy { held_symbols: &BTreeSet, selection_limit: usize, buffer_rank: usize, + daily_replacement_limit: usize, ) -> Vec { if selection_limit == 0 { return Vec::new(); @@ -849,7 +852,50 @@ impl PlatformExprStrategy { selected.push(symbol.clone()); } } - selected + if daily_replacement_limit == 0 || held_symbols.is_empty() { + return selected; + } + + let rank_by_symbol = ranked_symbols + .iter() + .enumerate() + .map(|(index, symbol)| (symbol.as_str(), index)) + .collect::>(); + let selected_set = selected.iter().map(String::as_str).collect::>(); + let available_held = held_symbols + .iter() + .filter(|symbol| rank_by_symbol.contains_key(symbol.as_str())) + .cloned() + .collect::>(); + let mut removed = available_held + .iter() + .filter(|symbol| !selected_set.contains(symbol.as_str())) + .cloned() + .collect::>(); + if removed.len() <= daily_replacement_limit { + return selected; + } + removed.sort_by_key(|symbol| std::cmp::Reverse(rank_by_symbol[symbol.as_str()])); + let permitted_removals = removed + .into_iter() + .take(daily_replacement_limit) + .collect::>(); + let mut limited = available_held + .into_iter() + .filter(|symbol| !permitted_removals.contains(symbol)) + .collect::>(); + limited.sort_by_key(|symbol| rank_by_symbol[symbol.as_str()]); + let mut limited_set = limited.iter().cloned().collect::>(); + for symbol in selected.iter().chain(ranked_symbols.iter()) { + if limited.len() >= selection_limit { + break; + } + if limited_set.insert(symbol.clone()) { + limited.push(symbol.clone()); + } + } + limited.sort_by_key(|symbol| rank_by_symbol[symbol.as_str()]); + limited } fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode { @@ -8709,6 +8755,11 @@ impl Strategy for PlatformExprStrategy { let mut risk_decisions = Vec::new(); let stock_list = if self.config.rotation_enabled && !in_skip_window { let selection_buffer_rank = self.selection_buffer_rank(selection_limit); + let ranked_selection_limit = if self.config.daily_replacement_limit > 0 { + usize::MAX + } else { + selection_buffer_rank + }; let (ranked_stock_list, notes, selection_risk_decisions) = self.select_symbols( ctx, selection_market_date, @@ -8717,7 +8768,7 @@ impl Strategy for PlatformExprStrategy { &day, band_low, band_high, - selection_buffer_rank, + ranked_selection_limit, )?; selection_notes = notes; risk_decisions = selection_risk_decisions; @@ -8732,6 +8783,7 @@ impl Strategy for PlatformExprStrategy { &held_symbols, selection_limit, selection_buffer_rank, + self.config.daily_replacement_limit, ) } else { Vec::new() @@ -9546,6 +9598,11 @@ impl Strategy for PlatformExprStrategy { } if periodic_rebalance { + let rebalance_existing_positions = self.config.rebalance_existing_positions + || self + .last_trading_ratio + .map(|previous| (previous - trading_ratio).abs() > 1e-12) + .unwrap_or(true); let pre_rebalance_symbols = projected .positions() .keys() @@ -9625,9 +9682,7 @@ impl Strategy for PlatformExprStrategy { && !same_day_sold_symbols.contains(symbol) && !pending_full_close_symbols.contains(symbol); if projected.positions().contains_key(symbol) && !released_exit_position { - if self.config.aiquant_transaction_cost - && !self.config.rebalance_existing_positions - { + if self.config.aiquant_transaction_cost && !rebalance_existing_positions { continue; } if self.config.aiquant_transaction_cost { @@ -26215,6 +26270,10 @@ mod tests { target_value_cfg.rebalance_existing_positions = true; target_value_cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()); + let hold_existing_cfg = PlatformExprStrategyConfig { + rebalance_existing_positions: false, + ..target_value_cfg.clone() + }; let mut target_value_strategy = PlatformExprStrategy::new(target_value_cfg); target_value_strategy.rebalance_day_counter = 20; @@ -26239,6 +26298,44 @@ mod tests { "{:?}", target_value_decision.order_intents ); + + let mut hold_existing_strategy = PlatformExprStrategy::new(hold_existing_cfg.clone()); + hold_existing_strategy.rebalance_day_counter = 20; + hold_existing_strategy.last_trading_ratio = Some(1.0); + let unchanged_exposure_decision = hold_existing_strategy + .on_day(&ctx) + .expect("hold-existing platform decision"); + assert!( + !unchanged_exposure_decision + .order_intents + .iter() + .any(|intent| matches!( + intent, + OrderIntent::TargetValue { reason, .. } + if reason == "periodic_rebalance_target_adjust" + )), + "{:?}", + unchanged_exposure_decision.order_intents + ); + + let mut changed_exposure_strategy = PlatformExprStrategy::new(hold_existing_cfg); + changed_exposure_strategy.rebalance_day_counter = 20; + changed_exposure_strategy.last_trading_ratio = Some(0.1); + let changed_exposure_decision = changed_exposure_strategy + .on_day(&ctx) + .expect("changed-exposure platform decision"); + assert!( + changed_exposure_decision + .order_intents + .iter() + .any(|intent| matches!( + intent, + OrderIntent::TargetValue { reason, .. } + if reason == "periodic_rebalance_target_adjust" + )), + "{:?}", + changed_exposure_decision.order_intents + ); } #[test] @@ -30137,7 +30234,7 @@ mod tests { .map(str::to_string) .collect::>(); - let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6); + let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 6, 0); assert_eq!( selected, @@ -30145,6 +30242,36 @@ mod tests { ); } + #[test] + fn selection_replacement_limit_caps_voluntary_daily_removals() { + let ranked = [ + "000005.SZ", + "000006.SZ", + "000007.SZ", + "000008.SZ", + "000001.SZ", + "000002.SZ", + "000003.SZ", + "000004.SZ", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + let held = ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"] + .into_iter() + .map(str::to_string) + .collect::>(); + + let selected = PlatformExprStrategy::buffered_selection(&ranked, &held, 4, 4, 2); + + assert_eq!( + selected, + vec!["000005.SZ", "000006.SZ", "000001.SZ", "000002.SZ"] + ); + let selected_set = selected.into_iter().collect::>(); + assert_eq!(held.difference(&selected_set).count(), 2); + } + #[test] fn lagged_rebalance_emits_target_value_even_when_projection_is_unchanged() { assert!(PlatformExprStrategy::should_emit_rebalance_target_value( diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 67523ae..24c3607 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -715,6 +715,8 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub selection_buffer_multiple: Option, #[serde(default)] + pub daily_replacement_limit: Option, + #[serde(default)] pub retry_empty_rebalance: Option, #[serde(default)] pub weak_market_shrink_overweight_threshold: Option, @@ -1702,6 +1704,9 @@ pub fn platform_expr_config_from_spec( { cfg.selection_buffer_multiple = multiple; } + if let Some(limit) = trading.daily_replacement_limit { + cfg.daily_replacement_limit = limit; + } if let Some(enabled) = trading.retry_empty_rebalance { cfg.retry_empty_rebalance = enabled; } @@ -3020,6 +3025,7 @@ mod tests { "dailyPositionTargetAdjust": false, "rebalanceExistingPositions": true, "selectionBufferMultiple": 1.5, + "dailyReplacementLimit": 2, "retryEmptyRebalance": false } } @@ -3031,6 +3037,7 @@ mod tests { assert!(!cfg.daily_position_target_adjust_enabled); assert!(cfg.rebalance_existing_positions); assert_eq!(cfg.selection_buffer_multiple, 1.5); + assert_eq!(cfg.daily_replacement_limit, 2); assert!(!cfg.retry_empty_rebalance); }