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 2b8f462dc1
3 changed files with 92 additions and 4 deletions
@@ -1856,9 +1856,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
if max_quantity < row.delta_quantity { if max_quantity < row.delta_quantity {
row.delta_quantity = max_quantity; row.delta_quantity = max_quantity;
row.target_quantity = row.current_quantity + max_quantity; row.target_quantity = row.current_quantity + max_quantity;
if max_quantity < allocation_quantity {
row.status = "REDUCE_TO_ALLOWED_QUANTITY".to_string(); row.status = "REDUCE_TO_ALLOWED_QUANTITY".to_string();
row.reason = "按当前可用资金缩量;卖出资金确认后需重新预览".to_string(); row.reason = "按当前可用资金缩量;卖出资金确认后需重新预览".to_string();
} }
}
remaining_cash -= cost(row.delta_quantity)?; remaining_cash -= cost(row.delta_quantity)?;
position_budget -= cost(row.delta_quantity)?; position_budget -= cost(row.delta_quantity)?;
if row.current_quantity == Decimal::ZERO { if row.current_quantity == Decimal::ZERO {
+23
View File
@@ -18,6 +18,10 @@ pub struct StockPoolEntryProgress {
pub first_decision_date: NaiveDate, pub first_decision_date: NaiveDate,
pub latest_generation: String, pub latest_generation: String,
pub latest_target_value: Decimal, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -37,6 +41,7 @@ pub struct StockPoolGoalObservation<'a> {
pub target_weight_bps: i32, pub target_weight_bps: i32,
pub target_value: Decimal, pub target_value: Decimal,
pub current_quantity: Decimal, pub current_quantity: Decimal,
pub target_quantity: Decimal,
pub status: &'a str, pub status: &'a str,
} }
@@ -80,6 +85,7 @@ impl StockPoolExecutionState {
} }
if self.entries.values().any(|entry| { if self.entries.values().any(|entry| {
entry.latest_target_value < Decimal::ZERO entry.latest_target_value < Decimal::ZERO
|| entry.completion_quantity.is_some_and(|quantity| quantity <= Decimal::ZERO)
|| entry.latest_generation.is_empty() || entry.latest_generation.is_empty()
|| self || self
.last_execution_date .last_execution_date
@@ -134,6 +140,15 @@ impl StockPoolExecutionState {
.retain(|symbol, _| members.contains(symbol) || held.contains(symbol)); .retain(|symbol, _| members.contains(symbol) || held.contains(symbol));
for (symbol, entry) in &mut next.entries { for (symbol, entry) in &mut next.entries {
entry.observed_holding |= held.contains(symbol); 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 next.removed_since
.retain(|symbol, _| held.contains(symbol) && !members.contains(symbol)); .retain(|symbol, _| held.contains(symbol) && !members.contains(symbol));
@@ -176,6 +191,7 @@ impl StockPoolExecutionState {
target_weight_bps: row.target_weight_bps, target_weight_bps: row.target_weight_bps,
target_value: row.target_value, target_value: row.target_value,
current_quantity: row.current_quantity, current_quantity: row.current_quantity,
target_quantity: row.target_quantity,
status: &row.status, status: &row.status,
}), }),
) )
@@ -202,6 +218,9 @@ impl StockPoolExecutionState {
.insert(row.symbol.into(), row.target_weight_bps); .insert(row.symbol.into(), row.target_weight_bps);
} }
let eligible = row.target_weight_bps > 0 && row.target_value > Decimal::ZERO; 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!( let satisfied = matches!(
row.status, row.status,
"ALREADY_SATISFIED" "ALREADY_SATISFIED"
@@ -215,6 +234,9 @@ impl StockPoolExecutionState {
if let Some(entry) = next.entries.get_mut(row.symbol) { if let Some(entry) = next.entries.get_mut(row.symbol) {
entry.latest_generation = generation.into(); entry.latest_generation = generation.into();
entry.latest_target_value = row.target_value; 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; entry.observed_holding |= row.current_quantity > Decimal::ZERO;
if entry.pending && eligible && satisfied { if entry.pending && eligible && satisfied {
entry.pending = false; entry.pending = false;
@@ -228,6 +250,7 @@ impl StockPoolExecutionState {
first_decision_date: decision_date, first_decision_date: decision_date,
latest_generation: generation.into(), latest_generation: generation.into(),
latest_target_value: row.target_value, latest_target_value: row.target_value,
completion_quantity,
}, },
); );
} }
@@ -48,6 +48,17 @@ fn plan(
positions: &[Position], positions: &[Position],
cash: i64, cash: i64,
outside: &str, outside: &str,
) -> StockPoolPlan {
plan_at_price(state, at, members, positions, cash, outside, 10)
}
fn plan_at_price(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
price: i64,
) -> StockPoolPlan { ) -> StockPoolPlan {
let symbols = members let symbols = members
.iter() .iter()
@@ -71,6 +82,10 @@ fn plan(
.unwrap(); .unwrap();
constraints.pending_entry_symbols = state.pending_symbols(); constraints.pending_entry_symbols = state.pending_symbols();
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at); constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
let mut market = quote();
market.last_price = price.into();
market.bid_price_1 = Some(price.into());
market.ask_price_1 = Some(price.into());
build_stock_pool_target_plan_with_constraints( build_stock_pool_target_plan_with_constraints(
&selection, &selection,
members, members,
@@ -81,7 +96,7 @@ fn plan(
frozen_cash: Decimal::ZERO, frozen_cash: Decimal::ZERO,
}, },
positions, positions,
&[quote()], &[market],
10000, 10000,
Decimal::ZERO, Decimal::ZERO,
outside, outside,
@@ -95,6 +110,54 @@ fn plan(
.unwrap() .unwrap()
} }
#[test]
fn fully_filled_entry_is_not_reopened_when_price_falls_before_next_observation() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 10000, "hold");
let state = initial.record_plan(day(11), "first-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, Some(1000.into()));
let state: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
let observed = state.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]).unwrap();
assert!(!observed.pending_symbols().contains("000001.SZ"));
let next = plan_at_price(&observed, day(14), &members, &[held(1000, 1000)], 1000, "hold", 8);
assert_eq!(next.rows[0].target_quantity, 1000.into());
assert_eq!(next.rows[0].delta_quantity, Decimal::ZERO);
assert_eq!(next.rows[0].status, "PRESERVED_EXISTING_POSITION");
}
#[test]
fn cash_clipped_or_rejected_entry_does_not_claim_full_completion() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[]).unwrap();
let first_plan = plan(&initial, day(11), &members, &[], 5000, "hold");
assert_eq!(first_plan.rows[0].status, "REDUCE_TO_ALLOWED_QUANTITY");
let state = initial.record_plan(day(11), "limited-entry", &first_plan).unwrap();
assert_eq!(state.entries["000001.SZ"].completion_quantity, None);
for quantity in [0, 500] {
let positions = if quantity == 0 { vec![] } else { vec![held(quantity, quantity)] };
let observed = state.observe(day(14), day(14), &calendar, &members, &positions).unwrap();
assert!(observed.pending_symbols().contains("000001.SZ"));
}
}
#[test]
fn legacy_state_without_quantity_keeps_its_serialized_identity() {
let original = json!({
"schema_version":1,"last_execution_date":"2026-09-11",
"entries":{"000001.SZ":{"pending":true,"observed_holding":false,
"first_decision_date":"2026-09-11","latest_generation":"legacy",
"latest_target_value":"10000"}},"last_target_weights":{},"removed_since":{}
});
let state: StockPoolExecutionState = serde_json::from_value(original.clone()).unwrap();
state.validate().unwrap();
assert_eq!(serde_json::to_value(state).unwrap(), original);
}
#[test] #[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() { fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()]; let members = vec![member()];
@@ -120,7 +183,7 @@ fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved()
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)]) .observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
.unwrap(); .unwrap();
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold"); let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
assert_eq!(satisfied.rows[0].status, "ENTRY_TARGET_ALREADY_SATISFIED"); assert_eq!(satisfied.rows[0].status, "PRESERVED_EXISTING_POSITION");
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap(); let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
assert!(!completed.entries["000001.SZ"].pending); assert!(!completed.entries["000001.SZ"].pending);
assert_eq!( assert_eq!(