fix(stock-pool): preserve exit roles and freeze relative reduction bases
This commit is contained in:
@@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress {
|
||||
pub completion_quantity: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -34,6 +42,10 @@ pub struct StockPoolExecutionState {
|
||||
pub last_target_weights: BTreeMap<String, i32>,
|
||||
/// First signal excluding an actually held member; not an acquisition date.
|
||||
pub removed_since: BTreeMap<String, NaiveDate>,
|
||||
/// 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<String, StockPoolPositionActionBasis>,
|
||||
}
|
||||
|
||||
pub struct StockPoolGoalObservation<'a> {
|
||||
@@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState {
|
||||
entries: BTreeMap::new(),
|
||||
last_target_weights: BTreeMap::new(),
|
||||
removed_since: BTreeMap::new(),
|
||||
position_action_bases: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +75,7 @@ impl StockPoolExecutionState {
|
||||
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());
|
||||
}
|
||||
@@ -70,6 +84,7 @@ impl StockPoolExecutionState {
|
||||
.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());
|
||||
@@ -97,6 +112,12 @@ impl StockPoolExecutionState {
|
||||
{
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -186,7 +207,7 @@ impl StockPoolExecutionState {
|
||||
self.record_targets(
|
||||
decision_date,
|
||||
generation,
|
||||
plan.rows.iter().map(|row| StockPoolGoalObservation {
|
||||
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,
|
||||
@@ -194,7 +215,67 @@ impl StockPoolExecutionState {
|
||||
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<String, Decimal> {
|
||||
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, String> {
|
||||
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<String, Decimal>,
|
||||
) -> Result<Self, String> {
|
||||
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>(
|
||||
@@ -213,6 +294,9 @@ impl StockPoolExecutionState {
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user