//! 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()); } Ok(()) } 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")) } pub(super) fn weights( original: &[String], active: &[String], members: &[StockPoolMemberSpec], explicit: &BTreeMap, 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) }