346 lines
14 KiB
Rust
346 lines
14 KiB
Rust
//! 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<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 {
|
|
pub schema_version: u32,
|
|
pub last_execution_date: Option<NaiveDate>,
|
|
pub entries: BTreeMap<String, StockPoolEntryProgress>,
|
|
#[serde(default)]
|
|
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> {
|
|
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, String> {
|
|
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::<BTreeSet<_>>();
|
|
let held = positions
|
|
.iter()
|
|
.filter(|position| position.quantity > Decimal::ZERO)
|
|
.map(|position| position.symbol.clone())
|
|
.collect::<BTreeSet<_>>();
|
|
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<String> {
|
|
self.entries
|
|
.iter()
|
|
.filter(|(_, entry)| entry.pending)
|
|
.map(|(symbol, _)| symbol.clone())
|
|
.collect()
|
|
}
|
|
|
|
pub fn next_day_exit_symbols(&self, execution_date: NaiveDate) -> BTreeSet<String> {
|
|
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, String> {
|
|
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<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>(
|
|
&self,
|
|
decision_date: NaiveDate,
|
|
generation: &str,
|
|
rows: impl IntoIterator<Item = StockPoolGoalObservation<'a>>,
|
|
) -> Result<Self, String> {
|
|
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)
|
|
}
|
|
}
|