From df29c8d3ec5b65c69237c1416eb499130e1e1965 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 04:33:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8C=BA=E5=88=86=E9=80=80=E5=87=BA=E5=90=8E?= =?UTF-8?q?=E6=9D=83=E9=87=8D=E9=87=8D=E5=88=86=E9=85=8D=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 183 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 9 +- 2 files changed, 186 insertions(+), 6 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 920a6d6..69ea40a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -197,6 +197,113 @@ fn validated_target_scales(scales: &[(String, f64)]) -> Result Result, BacktestError> { + if scales.is_empty() { + return Ok(Vec::new()); + } + let scale_total = scales.iter().map(|(_, scale)| *scale).sum::(); + if !scale_total.is_finite() || scale_total <= 0.0 { + return Err(BacktestError::Execution( + "target portfolio buy scale total must be positive".to_string(), + )); + } + let mut weights = scales + .iter() + .map(|(symbol, scale)| { + ( + symbol.clone(), + ((*scale / scale_total * 10_000.0) + 1e-9).floor() as u32, + ) + }) + .collect::>(); + let assigned = weights.iter().map(|(_, weight)| *weight).sum::(); + let remainder = 10_000_u32.saturating_sub(assigned); + let weight_count = weights.len(); + for index in 0..remainder as usize { + weights[index % weight_count].1 += 1; + } + Ok(weights) +} + +fn replenish_target_weight_bps( + original_weights: &[(String, u32)], + candidate_symbols: &[String], + excluded_symbols: &BTreeSet, + target_count: usize, +) -> Vec<(String, u32)> { + let original_by_symbol = original_weights.iter().cloned().collect::>(); + let mut active = original_weights + .iter() + .filter(|(symbol, weight)| *weight > 0 && !excluded_symbols.contains(symbol)) + .map(|(symbol, _)| symbol.clone()) + .collect::>(); + let mut seen = active.iter().cloned().collect::>(); + let mut promoted = Vec::new(); + for symbol in candidate_symbols { + if active.len() >= target_count { + break; + } + if excluded_symbols.contains(symbol) || !seen.insert(symbol.clone()) { + continue; + } + active.push(symbol.clone()); + promoted.push(symbol.clone()); + } + if active.is_empty() { + return Vec::new(); + } + + let mut result = active + .iter() + .map(|symbol| { + ( + symbol.clone(), + *original_by_symbol.get(symbol).unwrap_or(&0), + ) + }) + .collect::>(); + let assigned = result.iter().map(|(_, weight)| *weight).sum::(); + let missing = 10_000_u32.saturating_sub(assigned); + if missing == 0 { + return result; + } + let recipients = if promoted.is_empty() { + active + } else { + promoted + }; + let base_total = recipients + .iter() + .map(|symbol| *original_by_symbol.get(symbol).unwrap_or(&0)) + .sum::(); + let mut allocated = recipients + .iter() + .map(|symbol| { + if base_total > 0 { + (u64::from(missing) * u64::from(*original_by_symbol.get(symbol).unwrap_or(&0)) + / u64::from(base_total)) as u32 + } else { + missing / recipients.len() as u32 + } + }) + .collect::>(); + let allocated_total = allocated.iter().sum::(); + let recipient_count = allocated.len(); + for index in 0..missing.saturating_sub(allocated_total) as usize { + allocated[index % recipient_count] += 1; + } + let additions = recipients + .into_iter() + .zip(allocated) + .collect::>(); + for (symbol, weight) in &mut result { + *weight += additions.get(symbol).copied().unwrap_or(0); + } + result +} + fn replenish_target_scales( original_scales: &[(String, f64)], candidate_scales: &[(String, f64)], @@ -230,6 +337,42 @@ fn replenish_target_scales( active } +fn resolved_target_scales( + original_scales: &[(String, f64)], + candidate_scales: &[(String, f64)], + excluded_symbols: &BTreeSet, + target_count: usize, + redistribute_after_exit: bool, +) -> Result, BacktestError> { + if !redistribute_after_exit { + return Ok(replenish_target_scales( + original_scales, + candidate_scales, + excluded_symbols, + target_count, + )); + } + let original_weights = ordered_weight_bps_from_scales(original_scales)?; + let candidate_symbols = candidate_scales + .iter() + .map(|(symbol, _)| symbol.clone()) + .collect::>(); + Ok(replenish_target_weight_bps( + &original_weights, + &candidate_symbols, + excluded_symbols, + target_count, + ) + .into_iter() + .map(|(symbol, weight_bps)| { + ( + symbol, + f64::from(weight_bps) * target_count as f64 / 10_000.0, + ) + }) + .collect()) +} + fn target_value_for_scale( total_value: f64, exposure: f64, @@ -509,6 +652,7 @@ pub struct PlatformExprStrategyConfig { pub delayed_limit_open_exit_enabled: bool, pub delayed_limit_open_exit_time: Option, pub release_slot_on_exit_signal: bool, + pub redistribute_target_weights_after_exit: bool, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, pub subscription_guard_required: bool, @@ -584,6 +728,7 @@ impl PlatformExprStrategyConfig { delayed_limit_open_exit_enabled: false, delayed_limit_open_exit_time: None, release_slot_on_exit_signal: false, + redistribute_target_weights_after_exit: false, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, subscription_guard_required: false, @@ -12528,12 +12673,13 @@ impl Strategy for PlatformExprStrategy { } else { (Vec::new(), Vec::new()) }; - let target_portfolio_scales = replenish_target_scales( + let target_portfolio_scales = resolved_target_scales( &original_target_scales, &candidate_target_scales, &exit_symbols, selection_limit, - ); + self.config.redistribute_target_weights_after_exit, + )?; let target_portfolio_scale_by_symbol = target_portfolio_scales .iter() .cloned() @@ -13106,12 +13252,13 @@ impl Strategy for PlatformExprStrategy { } } - let daily_target_portfolio_scales = replenish_target_scales( + let daily_target_portfolio_scales = resolved_target_scales( &original_target_scales, &candidate_target_scales, &same_day_sold_symbols, selection_limit, - ); + self.config.redistribute_target_weights_after_exit, + )?; if daily_top_up_active && self.config.target_portfolio_daily_enabled { for (symbol, target_scale) in &daily_target_portfolio_scales { let target_value = target_value_for_scale( @@ -13547,7 +13694,8 @@ mod tests { PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral, StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements, framework_stock_rolling_factor_requirement, replenish_target_scales, - scheduled_position_exposure, target_value_for_scale, validated_target_scales, + resolved_target_scales, scheduled_position_exposure, target_value_for_scale, + validated_target_scales, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -13612,6 +13760,31 @@ mod tests { ); } + #[test] + fn stock_pool_exit_weight_redistribution_is_explicit_and_uses_integer_bps() { + let original = vec![ + ("A".to_string(), 1.0008), + ("B".to_string(), 1.0008), + ("C".to_string(), 0.9984), + ]; + let excluded = BTreeSet::from(["B".to_string()]); + + let preserved = resolved_target_scales(&original, &original, &excluded, 3, false) + .expect("preserved target scales"); + assert_eq!( + preserved, + vec![("A".to_string(), 1.0008), ("C".to_string(), 0.9984)] + ); + + let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true) + .expect("redistributed target scales"); + assert_eq!( + redistributed, + vec![("A".to_string(), 1.5021), ("C".to_string(), 1.4979)] + ); + assert!((redistributed.iter().map(|(_, scale)| *scale).sum::() - 3.0).abs() < 1e-12); + } + fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet { DataSet::from_components( vec![Instrument { diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 3ba3d67..efae343 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1020,6 +1020,8 @@ pub struct StrategyExpressionTradingConfig { #[serde(default)] pub release_slot_on_exit_signal: Option, #[serde(default)] + pub redistribute_target_weights_after_exit: Option, + #[serde(default)] pub subscription_guard_required: Option, #[serde(default)] pub subscriptions: Vec, @@ -2294,6 +2296,9 @@ pub fn platform_expr_config_from_spec( if let Some(enabled) = trading.release_slot_on_exit_signal { cfg.release_slot_on_exit_signal = enabled; } + if let Some(enabled) = trading.redistribute_target_weights_after_exit { + cfg.redistribute_target_weights_after_exit = enabled; + } if let Some(enabled) = trading.delayed_limit_open_exit { cfg.delayed_limit_open_exit_enabled = enabled; if enabled { @@ -3484,7 +3489,8 @@ mod tests { "targetPortfolioDaily": true, "rebalanceExistingPositions": true, "holdUntilExit": true, - "releaseSlotOnExitSignal": true + "releaseSlotOnExitSignal": true, + "redistributeTargetWeightsAfterExit": true } } }); @@ -3498,6 +3504,7 @@ mod tests { assert!(cfg.rebalance_existing_positions); assert!(cfg.hold_until_exit_enabled); assert!(cfg.release_slot_on_exit_signal); + assert!(cfg.redistribute_target_weights_after_exit); } #[test]