From ef24402747086da363a57da0a2e6d8f529100719 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 04:19:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E7=95=99=E7=AD=96=E7=95=A5=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E8=B5=84=E9=87=91=E6=AF=94=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 312 ++++++++---------- 1 file changed, 146 insertions(+), 166 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3fb0913..920a6d6 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -178,9 +178,7 @@ impl PlatformPortfolioDrawdownController { } } -fn ordered_weight_bps_from_scales( - scales: &[(String, f64)], -) -> Result, BacktestError> { +fn validated_target_scales(scales: &[(String, f64)]) -> Result, BacktestError> { if scales.is_empty() { return Ok(Vec::new()); } @@ -192,105 +190,56 @@ fn ordered_weight_bps_from_scales( "target portfolio buy scale must be finite and non-negative".to_string(), )); } - 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 + Ok(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) + .filter(|(_, scale)| *scale > 0.0) + .cloned() + .collect()) } -fn replenish_target_weight_bps( - original_weights: &[(String, u32)], - candidate_symbols: &[String], +fn replenish_target_scales( + original_scales: &[(String, f64)], + candidate_scales: &[(String, f64)], 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() { +) -> Vec<(String, f64)> { + if target_count == 0 { 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 mut active = Vec::with_capacity(target_count); + let mut seen = BTreeSet::new(); + for (symbol, scale) in original_scales { + if active.len() >= target_count { + break; + } + if *scale <= 0.0 || excluded_symbols.contains(symbol) || !seen.insert(symbol.clone()) { + continue; + } + active.push((symbol.clone(), *scale)); } - 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; + for (symbol, scale) in candidate_scales { + if active.len() >= target_count { + break; + } + if *scale <= 0.0 || excluded_symbols.contains(symbol) || !seen.insert(symbol.clone()) { + continue; + } + active.push((symbol.clone(), *scale)); } - let additions = recipients - .into_iter() - .zip(allocated) - .collect::>(); - for (symbol, weight) in &mut result { - *weight += additions.get(symbol).copied().unwrap_or(0); + active +} + +fn target_value_for_scale( + total_value: f64, + exposure: f64, + selection_limit: usize, + scale: f64, +) -> f64 { + if selection_limit == 0 { + return 0.0; } - result + total_value * exposure / selection_limit as f64 * scale } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -12536,9 +12485,8 @@ impl Strategy for PlatformExprStrategy { } let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone(); - let original_target_weights = + let (original_target_scales, candidate_target_scales) = if self.config.target_portfolio_daily_enabled && selection_limit > 0 { - let mut scales = Vec::new(); let original_target_symbols = self .last_target_order .as_ref() @@ -12551,6 +12499,7 @@ impl Strategy for PlatformExprStrategy { .map(|symbols| symbols.iter().cloned().collect()) }) .unwrap_or_else(|| stock_list.iter().take(selection_limit).cloned().collect()); + let mut original_scales = Vec::new(); for symbol in original_target_symbols.iter().take(selection_limit) { let decision_stock = self.stock_state_with_factor_date( ctx, @@ -12558,19 +12507,34 @@ impl Strategy for PlatformExprStrategy { selection_factor_date, symbol, )?; - scales.push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?)); + original_scales + .push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?)); } - ordered_weight_bps_from_scales(&scales)? + let mut candidate_scales = Vec::new(); + for symbol in &stock_list { + let decision_stock = self.stock_state_with_factor_date( + ctx, + decision_date, + selection_factor_date, + symbol, + )?; + candidate_scales + .push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?)); + } + ( + validated_target_scales(&original_scales)?, + validated_target_scales(&candidate_scales)?, + ) } else { - Vec::new() + (Vec::new(), Vec::new()) }; - let target_portfolio_weights = replenish_target_weight_bps( - &original_target_weights, - &stock_list, + let target_portfolio_scales = replenish_target_scales( + &original_target_scales, + &candidate_target_scales, &exit_symbols, selection_limit, ); - let target_portfolio_weight_bps = target_portfolio_weights + let target_portfolio_scale_by_symbol = target_portfolio_scales .iter() .cloned() .collect::>(); @@ -12614,19 +12578,16 @@ impl Strategy for PlatformExprStrategy { )?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let target_value = if self.config.target_portfolio_daily_enabled { - target_portfolio_weight_bps + let target_scale = target_portfolio_scale_by_symbol .get(&position.symbol) - .map(|weight_bps| { - strategy_visible_total_value - * trading_ratio - * f64::from(*weight_bps) - / 10_000.0 - }) - .unwrap_or( - strategy_visible_total_value * trading_ratio - / selection_limit as f64 - * stock_scale, - ) + .copied() + .unwrap_or(stock_scale); + target_value_for_scale( + strategy_visible_total_value, + trading_ratio, + selection_limit, + target_scale, + ) } else { strategy_visible_total_value * trading_ratio / selection_limit as f64 * stock_scale @@ -12730,19 +12691,16 @@ impl Strategy for PlatformExprStrategy { )?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; let target_value = if self.config.target_portfolio_daily_enabled { - target_portfolio_weight_bps + let target_scale = target_portfolio_scale_by_symbol .get(&symbol) - .map(|weight_bps| { - strategy_visible_total_value - * trading_ratio - * f64::from(*weight_bps) - / 10_000.0 - }) - .unwrap_or( - strategy_visible_total_value * trading_ratio - / selection_limit as f64 - * stock_scale, - ) + .copied() + .unwrap_or(stock_scale); + target_value_for_scale( + strategy_visible_total_value, + trading_ratio, + selection_limit, + target_scale, + ) } else { strategy_visible_total_value * trading_ratio / selection_limit as f64 * stock_scale @@ -13148,17 +13106,20 @@ impl Strategy for PlatformExprStrategy { } } - let daily_target_portfolio_weights = replenish_target_weight_bps( - &original_target_weights, - &stock_list, + let daily_target_portfolio_scales = replenish_target_scales( + &original_target_scales, + &candidate_target_scales, &same_day_sold_symbols, selection_limit, ); if daily_top_up_active && self.config.target_portfolio_daily_enabled { - for (symbol, weight_bps) in &daily_target_portfolio_weights { - let target_value = - strategy_visible_total_value * trading_ratio * f64::from(*weight_bps) - / 10_000.0; + for (symbol, target_scale) in &daily_target_portfolio_scales { + let target_value = target_value_for_scale( + strategy_visible_total_value, + trading_ratio, + selection_limit, + *target_scale, + ); if !target_value.is_finite() || target_value <= 0.0 { continue; } @@ -13262,7 +13223,6 @@ impl Strategy for PlatformExprStrategy { } let target_budget = strategy_visible_total_value * trading_ratio; - let fixed_buy_cash = target_budget / selection_limit as f64; let mut rebalance_working_symbols = slot_working_symbols.clone(); if self.config.release_slot_on_exit_signal { for symbol in &same_day_sold_symbols { @@ -13293,10 +13253,12 @@ impl Strategy for PlatformExprStrategy { if !rebalance_existing_positions { continue; } - let target_value = target_portfolio_weight_bps + let target_scale = target_portfolio_scale_by_symbol .get(symbol) - .map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0) - .unwrap_or(target_budget / selection_limit as f64 * stock_scale); + .copied() + .unwrap_or(stock_scale); + let target_value = + target_value_for_scale(target_budget, 1.0, selection_limit, target_scale); let before_qty = projected .position(symbol) .map(|position| position.quantity) @@ -13348,10 +13310,12 @@ impl Strategy for PlatformExprStrategy { { continue; } - let target_value = target_portfolio_weight_bps + let target_scale = target_portfolio_scale_by_symbol .get(symbol) - .map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0) - .unwrap_or(fixed_buy_cash * stock_scale); + .copied() + .unwrap_or(stock_scale); + let target_value = + target_value_for_scale(target_budget, 1.0, selection_limit, target_scale); if target_value <= 0.0 { continue; } @@ -13582,8 +13546,8 @@ mod tests { PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral, StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements, - framework_stock_rolling_factor_requirement, ordered_weight_bps_from_scales, - replenish_target_weight_bps, scheduled_position_exposure, + framework_stock_rolling_factor_requirement, replenish_target_scales, + scheduled_position_exposure, target_value_for_scale, validated_target_scales, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -13600,36 +13564,52 @@ mod tests { } #[test] - fn target_weight_replenishment_matches_integer_bps_pool_contract() { - let scales = (0..24) - .map(|index| { - ( - format!("S{index:02}"), - if index < 16 { 1.0008 } else { 0.9984 }, - ) - }) + fn target_scale_replenishment_preserves_strategy_cash_allocation() { + let scale = 30.0 / 31.0; + let original_scales = (0..12) + .map(|index| (format!("S{index:02}"), scale)) .collect::>(); - let original = ordered_weight_bps_from_scales(&scales).expect("weights"); + let candidate_scales = (0..20) + .map(|index| (format!("S{index:02}"), scale)) + .collect::>(); + let original = validated_target_scales(&original_scales).expect("valid scales"); + let candidates = validated_target_scales(&candidate_scales).expect("valid candidates"); + + let per_symbol = target_value_for_scale(10_000_000.0, 0.2, 30, scale); + assert!((per_symbol - 64_516.129_032_258_07).abs() < 1e-9); + assert!((per_symbol * 12.0 - 774_193.548_387_096_8).abs() < 1e-8); + let excluded = BTreeSet::from(["S01".to_string()]); - let candidates = scales - .iter() - .map(|(symbol, _)| symbol.clone()) - .collect::>(); - - let replenished = replenish_target_weight_bps(&original, &candidates, &excluded, 24); + let replenished = replenish_target_scales(&original, &candidates, &excluded, 12); let by_symbol = replenished.iter().cloned().collect::>(); - - assert_eq!(replenished.len(), 23); - assert_eq!( - replenished.iter().map(|(_, weight)| *weight).sum::(), - 10_000 - ); - assert_eq!(by_symbol.get("S00"), Some(&436)); - assert_eq!(by_symbol.get("S02"), Some(&436)); - assert_eq!(by_symbol.get("S03"), Some(&436)); - assert_eq!(by_symbol.get("S04"), Some(&435)); - assert_eq!(by_symbol.get("S16"), Some(&434)); + assert_eq!(replenished.len(), 12); assert!(!by_symbol.contains_key("S01")); + assert_eq!(by_symbol.get("S12"), Some(&scale)); + assert!(replenished.iter().all(|(_, value)| *value == scale)); + assert!( + (replenished.iter().map(|(_, value)| *value).sum::() - 12.0 * scale).abs() < 1e-12 + ); + + let without_replacement = replenish_target_scales(&original, &original, &excluded, 12); + assert_eq!(without_replacement.len(), 11); + assert!( + (without_replacement + .iter() + .map(|(_, value)| *value) + .sum::() + - 11.0 * scale) + .abs() + < 1e-12 + ); + assert!(replenish_target_scales(&original, &candidates, &BTreeSet::new(), 0).is_empty()); + + assert!(validated_target_scales(&[("BAD".to_string(), f64::NAN)]).is_err()); + assert!(validated_target_scales(&[("BAD".to_string(), -0.1)]).is_err()); + assert!( + validated_target_scales(&[("ZERO".to_string(), 0.0)]) + .expect("zero scale is valid cash retention") + .is_empty() + ); } fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet {