分离股票池精确预算比例与展示基点并持久化状态

This commit is contained in:
boris
2026-09-19 21:09:27 +08:00
parent 3c70a9273b
commit 5924e43f3d
7 changed files with 171 additions and 10 deletions
+91
View File
@@ -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],