242 lines
11 KiB
Rust
242 lines
11 KiB
Rust
//! Dated non-tradability and valuation are separate from execution quotations.
|
|
use super::*;
|
|
|
|
pub(super) fn validate(
|
|
signal_date: NaiveDate,
|
|
constraints: &StockPoolDecisionConstraints,
|
|
current: &BTreeMap<String, (Decimal, Decimal, Decimal)>,
|
|
) -> 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<String, i32>,
|
|
constraints: &StockPoolDecisionConstraints, reserved_slots: usize,
|
|
display: &BTreeMap<String, i32>,
|
|
) -> Result<BTreeMap<String, Decimal>, 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::<Decimal>();
|
|
// 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::<BTreeSet<_>>();
|
|
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::<Vec<_>>();
|
|
let promoted = free.iter().filter(|symbol| !free_original.contains(**symbol)).copied().collect::<Vec<_>>();
|
|
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::<Decimal>();
|
|
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<String, &MarketSnapshot>,
|
|
frozen: &BTreeMap<String, FrozenStockPoolPosition>,
|
|
) -> Result<Decimal, String> {
|
|
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<String>, StockPoolDecisionConstraints, BTreeMap<String,i32>) {
|
|
let symbols: Vec<String> = 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::new(1,24));
|
|
assert!(!ratios.contains_key(&symbols[2]));
|
|
assert!((ratios.values().copied().sum::<Decimal>()-Decimal::ONE).abs()<Decimal::new(1,24));
|
|
}
|
|
#[test]
|
|
fn legacy_paused_and_explicit_partial_budgets_are_not_reinterpreted() {
|
|
let (symbols,constraints,display)=configuration();
|
|
let ratios=sizing_ratios(&symbols,&symbols,&BTreeMap::new(),&constraints,0,&display).unwrap();
|
|
assert_eq!(ratios[&symbols[0]],Decimal::new(3334,4));
|
|
assert!((ratios[&symbols[1]]-Decimal::new(3333,4)).abs()<Decimal::new(1,24));
|
|
let partial=BTreeMap::from([(symbols[0].clone(),2000),(symbols[1].clone(),0)]);
|
|
assert_eq!(sizing_ratios(&symbols,&symbols,&partial,&constraints,0,&partial).unwrap(),
|
|
BTreeMap::from([(symbols[0].clone(),Decimal::new(2,1)),(symbols[1].clone(),Decimal::ZERO)]));
|
|
}
|
|
#[test]
|
|
fn precision_is_checked_before_frozen_budget_is_allocated() {
|
|
let (symbols,mut constraints,display)=configuration();
|
|
constraints.prior_target_weight_ratios.insert(symbols[0].clone(),Decimal::new(1001,3));
|
|
assert!(sizing_ratios(&symbols,&symbols,&BTreeMap::new(),&constraints,0,&display).is_err());
|
|
}
|
|
}
|
|
|
|
pub(super) fn weights(
|
|
original: &[String],
|
|
active: &[String],
|
|
members: &[StockPoolMemberSpec],
|
|
explicit: &BTreeMap<String, i32>,
|
|
constraints: &StockPoolDecisionConstraints,
|
|
reserved_slots: usize,
|
|
target_count: usize,
|
|
) -> Result<BTreeMap<String, i32>, String> {
|
|
let count = original.len() + reserved_slots;
|
|
let order = members
|
|
.iter()
|
|
.map(|member| (&member.symbol, member.requested_order))
|
|
.collect::<BTreeMap<_, _>>();
|
|
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::<Vec<_>>();
|
|
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::<i32>();
|
|
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::<Vec<_>>();
|
|
let total = free.iter().map(|(_, weight)| *weight).sum::<u32>();
|
|
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::<Vec<_>>();
|
|
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)
|
|
}
|