diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index a115dfc..7ced0b7 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -254,6 +254,10 @@ fn replenish_target_weight_bps( excluded_symbols: &BTreeSet, target_count: usize, ) -> Vec<(String, u32)> { + if target_count == 0 || original_weights.is_empty() { + return Vec::new(); + } + let total_weight = original_weights.iter().map(|(_, weight)| *weight).sum::(); let original_by_symbol = original_weights.iter().cloned().collect::>(); let mut active = original_weights .iter() @@ -286,7 +290,7 @@ fn replenish_target_weight_bps( }) .collect::>(); let assigned = result.iter().map(|(_, weight)| *weight).sum::(); - let missing = 10_000_u32.saturating_sub(assigned); + let missing = total_weight.saturating_sub(assigned); if missing == 0 { return result; } @@ -364,6 +368,7 @@ fn resolved_target_scales( excluded_symbols: &BTreeSet, target_count: usize, redistribute_after_exit: bool, + unavailable_symbols: &BTreeSet, ) -> Result, BacktestError> { if !redistribute_after_exit { return Ok(replenish_target_scales( @@ -374,15 +379,25 @@ fn resolved_target_scales( )); } let original_weights = ordered_weight_bps_from_scales(original_scales)?; + // 不可报价证券的原始预算及席位保留,不能将其预算重新归一到其他股票。 + let reserved_slots = original_weights + .iter() + .filter(|(symbol, _)| unavailable_symbols.contains(symbol)) + .count(); + let available_weights = original_weights + .into_iter() + .filter(|(symbol, _)| !unavailable_symbols.contains(symbol)) + .collect::>(); let candidate_symbols = candidate_scales .iter() + .filter(|(symbol, _)| !unavailable_symbols.contains(symbol)) .map(|(symbol, _)| symbol.clone()) .collect::>(); Ok(replenish_target_weight_bps( - &original_weights, + &available_weights, &candidate_symbols, excluded_symbols, - target_count, + target_count.saturating_sub(reserved_slots), ) .into_iter() .map(|(symbol, weight_bps)| { @@ -12663,6 +12678,27 @@ impl PlatformExprStrategy { for value in contexts.values() {let record=value.to_string();if !decision.diagnostics.contains(&record){decision.diagnostics.push(record);}} } + fn target_quote_unavailable_symbols( + &self, + ctx: &StrategyContext<'_>, + original_scales: &[(String, f64)], + candidates: &[(String, f64)], + ) -> Result, BacktestError> { + if ctx.is_lagged_execution() { + return Ok(BTreeSet::new()); + } + let symbols = original_scales.iter().chain(candidates).map(|(symbol, _)| symbol) + .collect::>(); + let mut unavailable = BTreeSet::new(); + for symbol in symbols { + // 只用实际执行时点已知的停牌事实;未知行情仍由原有严格读取返回错误。 + if self.stock_state(ctx, ctx.execution_date, symbol)?.paused { + unavailable.insert(symbol.clone()); + } + } + Ok(unavailable) + } + fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result { if self.config.signal_book.is_some() && self.config.explicit_action_schedule.is_some() { return Ok(StrategyDecision::default()); @@ -13387,12 +13423,16 @@ impl PlatformExprStrategy { } else { (Vec::new(), Vec::new()) }; + let quote_unavailable_symbols = self.target_quote_unavailable_symbols( + ctx, &original_target_scales, &candidate_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, + "e_unavailable_symbols, )?; let target_portfolio_scale_by_symbol = target_portfolio_scales .iter() @@ -13984,6 +14024,7 @@ impl PlatformExprStrategy { &exit_symbols, daily_target_count, self.config.redistribute_target_weights_after_exit, + "e_unavailable_symbols, )?; if daily_top_up_active && self.config.target_portfolio_daily_enabled { for (symbol, target_scale) in &daily_target_portfolio_scales { @@ -14736,14 +14777,14 @@ mod tests { ]; let excluded = BTreeSet::from(["B".to_string()]); - let preserved = resolved_target_scales(&original, &original, &excluded, 3, false) + let preserved = resolved_target_scales(&original, &original, &excluded, 3, false, &BTreeSet::new()) .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) + let redistributed = resolved_target_scales(&original, &original, &excluded, 3, true, &BTreeSet::new()) .expect("redistributed target scales"); assert_eq!( redistributed, @@ -14752,6 +14793,56 @@ mod tests { assert!((redistributed.iter().map(|(_, scale)| *scale).sum::() - 3.0).abs() < 1e-12); } + #[test] + fn stock_pool_suspension_keeps_budget_and_exit_redistributes_only_available_weights() { + let original = (0..24).map(|i| (format!("S{i:02}"), if i < 16 { 1.0008 } else { 0.9984 })).collect::>(); + let excluded = BTreeSet::from(["S02".to_string()]); + let paused = BTreeSet::from(["S15".to_string()]); + let resolved = resolved_target_scales(&original, &original, &excluded, 24, true, &paused).unwrap(); + let weights = resolved.iter().map(|(s, scale)| (s.clone(), (scale / 24.0 * 10000.0).round() as u32)).collect::>(); + assert_eq!(weights.len(), 22); + assert_eq!(weights.values().sum::(), 9583); + assert!(!weights.contains_key("S15")); + assert!(!weights.contains_key("S02")); + assert_eq!(weights["S11"], 436); + assert_eq!(weights["S14"], 436); + assert_eq!(weights["S22"], 435); + assert_eq!(weights["S23"], 434); + + let candidates = [("A".to_string(), 1.2), ("PAUSED".to_string(), 0.9), + ("EXIT".to_string(), 0.9), ("NEW".to_string(), 0.9)]; + let replacement = resolved_target_scales(&candidates[..3], &candidates, + &BTreeSet::from(["EXIT".to_string()]), 3, true, + &BTreeSet::from(["PAUSED".to_string()])).unwrap(); + assert_eq!(replacement, vec![("A".to_string(), 1.2), ("NEW".to_string(), 0.9)]); + let all_paused = original.iter().map(|(s, _)| s.clone()).collect::>(); + assert!(resolved_target_scales(&original, &original, &BTreeSet::new(), 24, true, &all_paused).unwrap().is_empty()); + } + + #[test] + fn stock_pool_suspension_resolution_never_reads_the_future_execution_day() { + let date = d(2025, 9, 29); + let symbol = "000001.SZ"; + let mut parts = single_symbol_platform_data(&[date], symbol).snapshot_components(); + parts.market[0].paused = true; + let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market, + parts.factors, parts.candidates, parts.benchmarks, Vec::new(), Vec::new()).unwrap(); + let portfolio = PortfolioState::new(100_000.0); + let subscriptions = BTreeSet::new(); + let mut ctx = StrategyContext { + execution_date: date, decision_date: date, decision_index: 0, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::generic()); + let targets = [(symbol.to_string(), 1.0)]; + assert_eq!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap(), + BTreeSet::from([symbol.to_string()])); + ctx.execution_date = d(2025, 9, 30); + assert!(strategy.target_quote_unavailable_symbols(&ctx, &targets, &targets).unwrap().is_empty()); + } + #[test] fn model_target_lifecycle_removes_exits_and_appends_replacements() { let date = d(2025, 1, 3);