fix(stock-pool): reconcile completed entry quantities before repricing

This commit is contained in:
boris
2026-09-12 04:29:40 +08:00
parent 6ffa0346aa
commit 9804851133
3 changed files with 92 additions and 4 deletions
+4 -2
View File
@@ -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)?;
+23
View File
@@ -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,
},
);
}