//! Dated non-tradability and valuation are separate from execution quotations. use super::*; pub(super) fn validate( signal_date: NaiveDate, constraints: &StockPoolDecisionConstraints, current: &BTreeMap, ) -> Result<(), String> { for (symbol, fact) in &constraints.frozen_positions { if constraints.execution_date != Some(fact.trade_date) || fact.trade_date < signal_date || fact.reason != "paused" || fact.valuation_price <= Decimal::ZERO || current.get(symbol).is_none_or(|row| row.0 <= Decimal::ZERO) { return Err(format!("stock_pool_frozen_position_invalid:{symbol}")); } } if constraints .prior_target_weights .iter() .any(|(symbol, weight)| { normalize_stock_symbol(symbol).as_ref() != Some(symbol) || !(0..=10_000).contains(weight) }) { return Err("stock_pool_prior_target_weights_invalid".into()); } if constraints.prior_target_weight_ratios.iter().any(|(symbol, ratio)| { normalize_stock_symbol(symbol).as_ref() != Some(symbol) || *ratio < Decimal::ZERO || *ratio > Decimal::ONE }) { return Err("stock_pool_prior_target_weight_ratios_invalid".into()); } Ok(()) } /// Never size cash with the rounded display bps. Paused holdings keep the /// actually recorded prior ratio. Legacy bps are preserved, not guessed as 1/N. pub(super) fn sizing_ratios( original: &[String], active: &[String], explicit: &BTreeMap, constraints: &StockPoolDecisionConstraints, reserved_slots: usize, display: &BTreeMap, ) -> Result, String> { if !explicit.is_empty() { return Ok(display.iter().map(|(symbol, weight)| (symbol.clone(), Decimal::from(*weight) / Decimal::from(10_000))).collect()); } if constraints.frozen_positions.is_empty() { let count = active.len() + reserved_slots; let share = if count == 0 { Decimal::ZERO } else { Decimal::ONE / Decimal::from(count as u64) }; return Ok(active.iter().map(|symbol| (symbol.clone(), share)).collect()); } let count = original.len() + reserved_slots; let base = if count == 0 { Decimal::ZERO } else { Decimal::ONE / Decimal::from(count as u64) }; let mut result = BTreeMap::new(); for symbol in constraints.frozen_positions.keys() { let ratio = constraints.prior_target_weight_ratios.get(symbol).copied() .or_else(|| constraints.prior_target_weights.get(symbol).map(|bps| Decimal::from(*bps) / Decimal::from(10_000))) .unwrap_or(base); result.insert(symbol.clone(), ratio); } let frozen_total = result.values().copied().sum::(); // Only allow last-digit residue from Decimal division, never a meaningful // over-allocation. All actual cash/fee checks remain downstream. if frozen_total > Decimal::ONE + Decimal::new(1, 24) { return Err("stock_pool_frozen_position_ratios_exceed_budget".into()); } let free_original = original.iter().filter(|symbol| !result.contains_key(*symbol)).collect::>(); let free_total = (base * Decimal::from(free_original.len() as u64)).min((Decimal::ONE - frozen_total).max(Decimal::ZERO)); let share = if free_original.is_empty() { Decimal::ZERO } else { free_total / Decimal::from(free_original.len() as u64) }; let free = active.iter().filter(|symbol| !constraints.frozen_positions.contains_key(*symbol)).collect::>(); let promoted = free.iter().filter(|symbol| !free_original.contains(**symbol)).copied().collect::>(); for symbol in &free { result.insert((*symbol).clone(), if free_original.contains(*symbol) { share } else { Decimal::ZERO }); } let assigned = free.iter().map(|symbol| result[*symbol]).sum::(); let missing = (free_total - assigned).max(Decimal::ZERO); let recipients = if promoted.is_empty() { &free } else { &promoted }; if !recipients.is_empty() { let addition = missing / Decimal::from(recipients.len() as u64); for symbol in recipients { *result.entry((*symbol).clone()).or_default() += addition; } } Ok(result) } pub(super) fn valuation( symbol: &str, quotes: &HashMap, frozen: &BTreeMap, ) -> Result { frozen .get(symbol) .map(|fact| fact.valuation_price) .or_else(|| quotes.get(symbol).map(|quote| quote.last_price)) .filter(|price| *price > Decimal::ZERO) .ok_or_else(|| format!("{symbol} confirmed holding valuation missing")) } #[cfg(test)] mod ratio_tests { use super::*; fn configuration() -> (Vec, StockPoolDecisionConstraints, BTreeMap) { let symbols: Vec = vec!["000001.SZ".into(),"000002.SZ".into(),"000003.SZ".into()]; let paused = FrozenStockPoolPosition { trade_date: NaiveDate::from_ymd_opt(2026,9,3).unwrap(), reason:"paused".into(), valuation_price:Decimal::from(10) }; let constraints = StockPoolDecisionConstraints { frozen_positions:BTreeMap::from([(symbols[0].clone(),paused)]), prior_target_weights:BTreeMap::from([(symbols[0].clone(),3334)]), ..Default::default() }; let display = BTreeMap::from([(symbols[0].clone(),3334),(symbols[1].clone(),3333),(symbols[2].clone(),3333)]); (symbols,constraints,display) } #[test] fn precise_paused_budget_survives_replacement_and_zero_targets() { let (symbols,mut constraints,display)=configuration(); let third=Decimal::ONE/Decimal::from(3); constraints.prior_target_weight_ratios.insert(symbols[0].clone(),third); let mut active=symbols.clone();active[2]="000004.SZ".into(); let ratios=sizing_ratios(&symbols,&active,&BTreeMap::new(),&constraints,0,&display).unwrap(); assert_eq!(ratios[&symbols[0]],third); assert_eq!(ratios[&symbols[1]],third); assert!((ratios["000004.SZ"]-third).abs()()-Decimal::ONE).abs(), constraints: &StockPoolDecisionConstraints, reserved_slots: usize, target_count: usize, ) -> Result, String> { let count = original.len() + reserved_slots; let order = members .iter() .map(|member| (&member.symbol, member.requested_order)) .collect::>(); let mut original_budget_symbols = original.to_vec(); for symbol in constraints.frozen_positions.keys() { if order.contains_key(symbol) && !original_budget_symbols.contains(symbol) { original_budget_symbols.push(symbol.clone()); } } if original_budget_symbols.len() != original.len() { original_budget_symbols .sort_by_key(|symbol| order.get(symbol).copied().unwrap_or(i32::MAX)); } let initial = original_budget_symbols .iter() .enumerate() .map(|(index, symbol)| { let weight = if explicit.is_empty() { if count == 0 { 0 } else { 10_000 / count as i32 + i32::from(index < 10_000 % count) } } else { *explicit.get(symbol).unwrap_or(&0) }; (symbol.clone(), weight) }) .collect::>(); let mut frozen = BTreeMap::new(); for symbol in constraints.frozen_positions.keys() { let weight = explicit .get(symbol) .copied() .or_else(|| constraints.prior_target_weights.get(symbol).copied()) .or_else(|| { initial .iter() .find(|(key, _)| key == symbol) .map(|(_, weight)| *weight) }) .ok_or_else(|| format!("stock_pool_frozen_position_target_weight_missing:{symbol}"))?; frozen.insert(symbol.clone(), weight); } let frozen_total = frozen.values().copied().sum::(); if frozen_total > 10_000 { return Err("stock_pool_frozen_position_weights_exceed_budget".into()); } let mut free = initial .into_iter() .filter(|(symbol, _)| !frozen.contains_key(symbol)) .map(|(symbol, weight)| (symbol, weight as u32)) .collect::>(); let total = free.iter().map(|(_, weight)| *weight).sum::(); let available = (10_000 - frozen_total) as u32; // A paused holding removed from today's candidates still owns its prior // budget. Scale only the new tradable allocation, never the frozen leg. if total > available { let mut remainder = available; for (_, weight) in &mut free { *weight = (u64::from(*weight) * u64::from(available) / u64::from(total)) as u32; remainder -= *weight; } for (_, weight) in free.iter_mut().take(remainder as usize) { *weight += 1; } } let excluded = free .iter() .filter(|(symbol, _)| !active.contains(symbol)) .map(|(symbol, _)| symbol.clone()) .collect(); let candidates = active .iter() .filter(|symbol| !frozen.contains_key(*symbol)) .cloned() .collect::>(); let allocated = crate::platform_expr_strategy::replenish_target_weight_bps( &free, &candidates, &excluded, target_count.saturating_sub( frozen .keys() .filter(|symbol| original.contains(symbol)) .count(), ), ); frozen.extend( allocated .into_iter() .map(|(symbol, weight)| (symbol, weight as i32)), ); Ok(frozen) }