151 lines
5.2 KiB
Rust
151 lines
5.2 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());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
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)
|
|
}
|