分离股票池精确预算比例与展示基点并持久化状态
This commit is contained in:
@@ -26,9 +26,58 @@ pub(super) fn validate(
|
||||
{
|
||||
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>,
|
||||
@@ -42,6 +91,48 @@ pub(super) fn valuation(
|
||||
.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!["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],
|
||||
|
||||
Reference in New Issue
Block a user