//! Durable intent progress, deliberately separate from actual-fill holding //! protection. A published target starts no holding/protection timer. use std::collections::{BTreeMap, BTreeSet}; use chrono::NaiveDate; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use crate::stock_pool_execution::{ Position, StockPoolMemberSpec, StockPoolPlan, normalize_stock_symbol, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolEntryProgress { pub pending: bool, pub observed_holding: bool, pub first_decision_date: NaiveDate, pub latest_generation: String, pub latest_target_value: Decimal, /// Fully funded entry goal, fixed at the last plan. Reconcile against /// actual holdings before repricing, never against today's market value. #[serde(default, skip_serializing_if = "Option::is_none")] pub completion_quantity: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolPositionActionBasis { pub generation: String, pub first_execution_date: NaiveDate, pub quantity: Decimal, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolExecutionState { pub schema_version: u32, pub last_execution_date: Option, pub entries: BTreeMap, #[serde(default)] pub last_target_weights: BTreeMap, /// First signal excluding an actually held member; not an acquisition date. pub removed_since: BTreeMap, /// Signal progress, not a fill or holding-period fact. Kept across retries /// and later execution sessions until a new generation supersedes it. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub position_action_bases: BTreeMap, } pub struct StockPoolGoalObservation<'a> { pub symbol: &'a str, pub target_weight_bps: i32, pub target_value: Decimal, pub current_quantity: Decimal, pub target_quantity: Decimal, pub status: &'a str, } impl Default for StockPoolExecutionState { fn default() -> Self { Self { schema_version: 1, last_execution_date: None, entries: BTreeMap::new(), last_target_weights: BTreeMap::new(), removed_since: BTreeMap::new(), position_action_bases: BTreeMap::new(), } } } impl StockPoolExecutionState { pub fn validate(&self) -> Result<(), String> { if self.schema_version != 1 || self.entries.len() > 10000 || self.removed_since.len() > 10000 || self.position_action_bases.len() > 10000 { return Err("stock_pool_execution_state_invalid_schema_or_size".into()); } for symbol in self .entries .keys() .chain(self.removed_since.keys()) .chain(self.last_target_weights.keys()) .chain(self.position_action_bases.keys()) { if normalize_stock_symbol(symbol).as_ref() != Some(symbol) { return Err("stock_pool_execution_state_invalid_symbol".into()); } } if self.last_target_weights.len() > 10000 || self .last_target_weights .values() .any(|value| !(0..=10000).contains(value)) { return Err("stock_pool_execution_state_invalid_weights".into()); } if self.entries.values().any(|entry| { entry.latest_target_value < Decimal::ZERO || entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO) || entry.latest_generation.is_empty() || self .last_execution_date .is_none_or(|last| entry.first_decision_date > last) }) || self .removed_since .values() .any(|day| self.last_execution_date.is_none_or(|last| *day > last)) { return Err("stock_pool_execution_state_invalid_goal_or_clock".into()); } if self.position_action_bases.values().any(|basis| { basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO || self.last_execution_date.is_none_or(|date| basis.first_execution_date > date) }) { return Err("stock_pool_execution_state_invalid_action_basis".into()); } Ok(()) } pub fn observe( &self, decision_date: NaiveDate, execution_date: NaiveDate, official_dates: &[NaiveDate], members: &[StockPoolMemberSpec], positions: &[Position], ) -> Result { self.validate()?; if decision_date > execution_date || !official_dates.contains(&execution_date) || !official_dates.contains(&decision_date) || official_dates.windows(2).any(|pair| pair[0] >= pair[1]) || self .last_execution_date .is_some_and(|last| last > execution_date) { return Err("stock_pool_execution_state_requires_monotone_official_clock".into()); } let mut next = self.clone(); next.last_execution_date = Some(execution_date); let members = members .iter() .map(|member| member.symbol.clone()) .collect::>(); let held = positions .iter() .filter(|position| position.quantity > Decimal::ZERO) .map(|position| position.symbol.clone()) .collect::>(); next.entries.retain(|symbol, entry| { // Confirmed flat starts a new cycle. A still-unfilled fresh target // may remain pending while the latest pool still requests it. !(entry.observed_holding && !held.contains(symbol)) && (members.contains(symbol) || held.contains(symbol)) }); next.last_target_weights .retain(|symbol, _| members.contains(symbol) || held.contains(symbol)); for (symbol, entry) in &mut next.entries { entry.observed_holding |= held.contains(symbol); if entry.pending && entry.completion_quantity.is_some_and(|goal| { positions.iter().any(|position| { &position.symbol == symbol && position.quantity >= goal }) }) { entry.pending = false; } } next.removed_since .retain(|symbol, _| held.contains(symbol) && !members.contains(symbol)); for symbol in held.difference(&members) { next.removed_since .entry(symbol.clone()) .or_insert(decision_date); } next.validate()?; Ok(next) } pub fn pending_symbols(&self) -> BTreeSet { self.entries .iter() .filter(|(_, entry)| entry.pending) .map(|(symbol, _)| symbol.clone()) .collect() } pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet { self.removed_since .iter() .filter(|(_, removed)| **removed < execution_date) .map(|(symbol, _)| symbol.clone()) .collect() } pub fn record_plan( &self, decision_date: NaiveDate, generation: &str, plan: &StockPoolPlan, ) -> Result { self.record_targets( decision_date, generation, plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation { symbol: &row.symbol, target_weight_bps: row.target_weight_bps, target_value: row.target_value, current_quantity: row.current_quantity, target_quantity: row.target_quantity, status: &row.status, }), )?.record_position_action_bases(generation, &plan.position_action_bases) } pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap { self.position_action_bases.iter() .filter(|(_, basis)| basis.generation == generation) .map(|(symbol, basis)| (symbol.clone(), basis.quantity)) .collect() } /// A verified split changes the share unit, not the intended reduction or /// entry completion. Never infer a split from a changed holding quantity. pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result { self.validate()?; if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) { return Err("stock_pool_execution_state_split_invalid".into()); } let scale = |quantity: Decimal| quantity.checked_mul(ratio) .map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero)) .ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string()); let mut next = self.clone(); if let Some(entry) = next.entries.get_mut(symbol) { if let Some(quantity) = entry.completion_quantity { let quantity = scale(quantity)?; entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity); } } if let Some(basis) = next.position_action_bases.get_mut(symbol) { basis.quantity = scale(basis.quantity)?; if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); } } next.validate()?; Ok(next) } pub fn record_position_action_bases( &self, generation: &str, quantities: &BTreeMap, ) -> Result { self.validate()?; if generation.trim().is_empty() { return Err("stock_pool_execution_state_action_generation_missing".into()); } let first_execution_date = self.last_execution_date .ok_or("stock_pool_execution_state_action_clock_missing")?; let mut next = self.clone(); next.position_action_bases.retain(|_, basis| basis.generation == generation); for (symbol, quantity) in quantities { if let Some(basis) = next.position_action_bases.get(symbol) { if basis.quantity != *quantity { return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}")); } } else { next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis { generation: generation.into(), first_execution_date, quantity: *quantity, }); } } next.validate()?; Ok(next) } pub fn record_targets<'a>( &self, decision_date: NaiveDate, generation: &str, rows: impl IntoIterator>, ) -> Result { self.validate()?; if generation.is_empty() || self .last_execution_date .is_none_or(|date| decision_date > date) { return Err("stock_pool_execution_state_plan_clock_invalid".into()); } let mut next = self.clone(); for row in rows { if row.status == "AUTOMATIC_TRADE_PROTECTED" { continue; } if row.target_weight_bps > 0 { next.last_target_weights .insert(row.symbol.into(), row.target_weight_bps); } let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO; let completion_quantity = (row.status == "READY" && row.target_quantity > row.current_quantity) .then_some(row.target_quantity); let satisfied = matches!( row.status, "ALREADY_SATISFIED" | "ENTRY_TARGET_ALREADY_SATISFIED" | "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED" ); if row.current_quantity == Decimal::ZERO && eligible && satisfied { next.entries.remove(row.symbol); continue; } if let Some(entry) = next.entries.get_mut(row.symbol) { entry.latest_generation = generation.into(); entry.latest_target_value = row.target_value; if entry.pending && completion_quantity.is_some() { entry.completion_quantity = completion_quantity; } entry.observed_holding |= row.current_quantity > Decimal::ZERO; if entry.pending && eligible && satisfied { entry.pending = false; } } else if eligible && row.current_quantity == Decimal::ZERO && !satisfied { next.entries.insert( row.symbol.into(), StockPoolEntryProgress { pending: true, observed_holding: false, first_decision_date: decision_date, latest_generation: generation.into(), latest_target_value: row.target_value, completion_quantity, }, ); } } next.validate()?; Ok(next) } }