diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index bb6de7b..fcaae50 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -178,6 +178,121 @@ impl PlatformPortfolioDrawdownController { } } +fn ordered_weight_bps_from_scales( + scales: &[(String, f64)], +) -> Result, BacktestError> { + if scales.is_empty() { + return Ok(Vec::new()); + } + if scales + .iter() + .any(|(_, scale)| !scale.is_finite() || *scale < 0.0) + { + return Err(BacktestError::Execution( + "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 + .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 +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SelectionRiskDeferral { None, @@ -12416,6 +12531,32 @@ impl Strategy for PlatformExprStrategy { } let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone(); + let target_portfolio_weight_bps = + if self.config.target_portfolio_daily_enabled && selection_limit > 0 { + let mut scales = Vec::new(); + for symbol in stock_list.iter().take(selection_limit) { + let decision_stock = self.stock_state_with_factor_date( + ctx, + decision_date, + selection_factor_date, + symbol, + )?; + scales.push((symbol.clone(), self.buy_scale(ctx, &day, &decision_stock)?)); + } + let original_weights = ordered_weight_bps_from_scales(&scales)?; + let mut excluded_target_symbols = exit_symbols.clone(); + excluded_target_symbols.extend(stop_take_exit_signal_symbols.iter().cloned()); + replenish_target_weight_bps( + &original_weights, + &stock_list, + &excluded_target_symbols, + selection_limit, + ) + .into_iter() + .collect::>() + } else { + BTreeMap::new() + }; if self.config.rotation_enabled && self.config.daily_position_target_adjust_enabled @@ -12457,9 +12598,24 @@ impl Strategy for PlatformExprStrategy { &position.symbol, )?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; - let target_value = strategy_visible_total_value * trading_ratio - / selection_limit as f64 - * stock_scale; + let target_value = if self.config.target_portfolio_daily_enabled { + target_portfolio_weight_bps + .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, + ) + } else { + strategy_visible_total_value * trading_ratio / selection_limit as f64 + * stock_scale + }; if !target_value.is_finite() || target_value <= 0.0 { continue; } @@ -12558,9 +12714,24 @@ impl Strategy for PlatformExprStrategy { &symbol, )?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; - let target_value = strategy_visible_total_value * trading_ratio - / selection_limit as f64 - * stock_scale; + let target_value = if self.config.target_portfolio_daily_enabled { + target_portfolio_weight_bps + .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, + ) + } else { + strategy_visible_total_value * trading_ratio / selection_limit as f64 + * stock_scale + }; if !target_value.is_finite() || target_value <= 0.0 { continue; } @@ -12999,9 +13170,14 @@ impl Strategy for PlatformExprStrategy { &symbol, )?; let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; - let target_value = strategy_visible_total_value * trading_ratio - / selection_limit as f64 - * stock_scale; + let target_value = if let Some(weight_bps) = + target_portfolio_weight_bps.get(&symbol) + { + strategy_visible_total_value * trading_ratio * f64::from(*weight_bps) / 10_000.0 + } else { + strategy_visible_total_value * trading_ratio / selection_limit as f64 + * stock_scale + }; if !target_value.is_finite() || target_value <= 0.0 { continue; } @@ -13166,7 +13342,10 @@ impl Strategy for PlatformExprStrategy { if !rebalance_existing_positions { continue; } - let target_value = target_budget / selection_limit as f64 * stock_scale; + let target_value = target_portfolio_weight_bps + .get(symbol) + .map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0) + .unwrap_or(target_budget / selection_limit as f64 * stock_scale); let before_qty = projected .position(symbol) .map(|position| position.quantity) @@ -13218,7 +13397,10 @@ impl Strategy for PlatformExprStrategy { { continue; } - let target_value = fixed_buy_cash * stock_scale; + let target_value = target_portfolio_weight_bps + .get(symbol) + .map(|weight_bps| target_budget * f64::from(*weight_bps) / 10_000.0) + .unwrap_or(fixed_buy_cash * stock_scale); if target_value <= 0.0 { continue; } @@ -13444,7 +13626,8 @@ mod tests { PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral, StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements, - framework_stock_rolling_factor_requirement, scheduled_position_exposure, + framework_stock_rolling_factor_requirement, ordered_weight_bps_from_scales, + replenish_target_weight_bps, scheduled_position_exposure, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -13460,6 +13643,39 @@ mod tests { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } + #[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 }, + ) + }) + .collect::>(); + let original = ordered_weight_bps_from_scales(&scales).expect("weights"); + 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 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!(!by_symbol.contains_key("S01")); + } + fn single_symbol_platform_data(dates: &[NaiveDate], symbol: &str) -> DataSet { DataSet::from_components( vec![Instrument {