Files
fidc-backtest-engine/crates/fidc-core/src/stock_pool_frozen.rs
T

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)
}