fix(stock-pool): reconcile completed entry quantities before repricing
This commit is contained in:
@@ -1856,8 +1856,10 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
if max_quantity < row.delta_quantity {
|
||||
row.delta_quantity = max_quantity;
|
||||
row.target_quantity = row.current_quantity + max_quantity;
|
||||
row.status = "REDUCE_TO_ALLOWED_QUANTITY".to_string();
|
||||
row.reason = "按当前可用资金缩量;卖出资金确认后需重新预览".to_string();
|
||||
if max_quantity < allocation_quantity {
|
||||
row.status = "REDUCE_TO_ALLOWED_QUANTITY".to_string();
|
||||
row.reason = "按当前可用资金缩量;卖出资金确认后需重新预览".to_string();
|
||||
}
|
||||
}
|
||||
remaining_cash -= cost(row.delta_quantity)?;
|
||||
position_budget -= cost(row.delta_quantity)?;
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct StockPoolEntryProgress {
|
||||
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)]
|
||||
@@ -37,6 +41,7 @@ pub struct StockPoolGoalObservation<'a> {
|
||||
pub target_weight_bps: i32,
|
||||
pub target_value: Decimal,
|
||||
pub current_quantity: Decimal,
|
||||
pub target_quantity: Decimal,
|
||||
pub status: &'a str,
|
||||
}
|
||||
|
||||
@@ -80,6 +85,7 @@ impl StockPoolExecutionState {
|
||||
}
|
||||
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
|
||||
@@ -134,6 +140,15 @@ impl StockPoolExecutionState {
|
||||
.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));
|
||||
@@ -176,6 +191,7 @@ impl StockPoolExecutionState {
|
||||
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,
|
||||
}),
|
||||
)
|
||||
@@ -202,6 +218,9 @@ impl StockPoolExecutionState {
|
||||
.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"
|
||||
@@ -215,6 +234,9 @@ impl StockPoolExecutionState {
|
||||
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;
|
||||
@@ -228,6 +250,7 @@ impl StockPoolExecutionState {
|
||||
first_decision_date: decision_date,
|
||||
latest_generation: generation.into(),
|
||||
latest_target_value: row.target_value,
|
||||
completion_quantity,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user