From 98048511335edc5b3da5e7ea312cd6513642aba4 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 04:29:40 +0800 Subject: [PATCH] fix(stock-pool): reconcile completed entry quantities before repricing --- crates/fidc-core/src/stock_pool_execution.rs | 6 +- crates/fidc-core/src/stock_pool_state.rs | 23 +++++++ .../tests/stock_pool_execution_state.rs | 67 ++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/crates/fidc-core/src/stock_pool_execution.rs b/crates/fidc-core/src/stock_pool_execution.rs index 9a2f329..b760dda 100644 --- a/crates/fidc-core/src/stock_pool_execution.rs +++ b/crates/fidc-core/src/stock_pool_execution.rs @@ -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)?; diff --git a/crates/fidc-core/src/stock_pool_state.rs b/crates/fidc-core/src/stock_pool_state.rs index 3ebc19d..2da624e 100644 --- a/crates/fidc-core/src/stock_pool_state.rs +++ b/crates/fidc-core/src/stock_pool_state.rs @@ -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, } #[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, }, ); } diff --git a/crates/fidc-core/tests/stock_pool_execution_state.rs b/crates/fidc-core/tests/stock_pool_execution_state.rs index 7b7879c..978044c 100644 --- a/crates/fidc-core/tests/stock_pool_execution_state.rs +++ b/crates/fidc-core/tests/stock_pool_execution_state.rs @@ -48,6 +48,17 @@ fn plan( positions: &[Position], cash: i64, 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 { let symbols = members .iter() @@ -71,6 +82,10 @@ fn plan( .unwrap(); constraints.pending_entry_symbols = state.pending_symbols(); 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( &selection, members, @@ -81,7 +96,7 @@ fn plan( frozen_cash: Decimal::ZERO, }, positions, - &[quote()], + &[market], 10000, Decimal::ZERO, outside, @@ -95,6 +110,54 @@ fn plan( .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] fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() { 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)]) .unwrap(); 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(); assert!(!completed.entries["000001.SZ"].pending); assert_eq!(