diff --git a/crates/fidc-core/src/stock_pool_execution.rs b/crates/fidc-core/src/stock_pool_execution.rs index fd673a8..bf5018f 100644 --- a/crates/fidc-core/src/stock_pool_execution.rs +++ b/crates/fidc-core/src/stock_pool_execution.rs @@ -1588,7 +1588,17 @@ pub fn build_stock_pool_target_plan_with_fee_model( if sizing_price <= Decimal::ZERO { return Err(format!("{symbol} execution sizing price is invalid")); } - let raw_target = (target_value / sizing_price).floor(); + // Existing shares are marked at the observed market price. Only the + // new buy leg pays its executable/slippage price; repricing the whole + // position would charge fictitious slippage and miss a board lot. + let current_value = current_quantity * quote.last_price; + let raw_target = if target_value >= current_value { + current_quantity + ((target_value - current_value) / sizing_price).floor() + } else { + // Sale slippage changes proceeds, not the marked shares we must + // remove to reach a market-value target. + (target_value / quote.last_price).floor() + }; let (step, minimum_buy) = order_quantity_rules(quote)?; let mut target_quantity = current_quantity; let mut delta = Decimal::ZERO; @@ -1911,7 +1921,7 @@ pub fn build_stock_pool_target_plan_with_fee_model( let cost = |quantity: Decimal| { Ok(quantity * price + fee_for(&row.symbol, OrderSide::Buy, quantity * price)?) }; - let own_budget = (row.target_value - row.current_quantity * price).max(Decimal::ZERO); + let own_budget = (row.target_value - row.current_quantity * quote.last_price).max(Decimal::ZERO); let allocation_quantity = max_affordable_buy_quantity_with_cost( own_budget, row.delta_quantity, diff --git a/crates/fidc-core/src/stock_pool_execution_tests.rs b/crates/fidc-core/src/stock_pool_execution_tests.rs index b3e0dd5..d43694e 100644 --- a/crates/fidc-core/src/stock_pool_execution_tests.rs +++ b/crates/fidc-core/src/stock_pool_execution_tests.rs @@ -2,12 +2,12 @@ use super::*; #[test] fn equal_thirty_seats_use_full_precision_at_a_board_lot_boundary() { - for (equity, price, held) in [("999377.147617", "3.070307", 2000), ("995624.8819", "6.420642", 900)] { + for (equity, price, executable, held) in [("999377.147617", "3.07", "3.070307", 2000), ("995624.8819", "6.42", "6.420642", 900)] { let pool = members(30); let mut market = quotes(30); let last = market.last_mut().unwrap(); last.last_price = price.parse().unwrap(); - last.buy_sizing_price = Some(last.last_price); + last.buy_sizing_price = Some(executable.parse().unwrap()); let positions = vec![Position { symbol: last.symbol.clone(), quantity: held.into(), closable_quantity: held.into(), average_cost: last.last_price }]; let mut selection = selection(30, 30); let constraints = StockPoolDecisionConstraints { target_holding_count: Some(30), reserve_cash_slots: 1, ..Default::default() }; @@ -32,6 +32,19 @@ fn equal_thirty_seats_use_full_precision_at_a_board_lot_boundary() { assert_eq!(state, restored); } } + +#[test] +fn sale_slippage_does_not_prevent_a_marked_value_board_lot_reduction() { + let pool = members(1); + let mut market = quotes(1); + market[0].sell_sizing_price = Some(Decimal::new(99,1)); + let positions = vec![Position { symbol:symbol(1),quantity:200.into(),closable_quantity:200.into(),average_cost:10.into() }]; + let plan = build_stock_pool_target_plan_with_constraints(&selection(1,1),&pool,&StockPoolExecutionRule::default(), + &AccountSnapshot {total_equity:2000.into(),cash:Decimal::ZERO,frozen_cash:Decimal::ZERO},&positions,&market, + 5000,Decimal::ZERO,"hold","full_rebalance",&StockPoolDecisionConstraints::default(),"reduce",Decimal::ZERO,Decimal::ZERO,Decimal::ZERO).unwrap(); + assert_eq!(plan.rows[0].target_value,Decimal::from(1000)); + assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-100)); +} use serde_json::json; fn symbol(index: usize) -> String { diff --git a/docs/stock-pool-ratio-precision-20260919.md b/docs/stock-pool-ratio-precision-20260919.md index bb158de..0a9d70c 100644 --- a/docs/stock-pool-ratio-precision-20260919.md +++ b/docs/stock-pool-ratio-precision-20260919.md @@ -2,6 +2,8 @@ 此前按10000整数基点分配等权,然后反算资金。30只股票的333/334基点并不等于1/30,在临界整手处会漏补仓。修复将 `target_weight_bps` 保留为展示/旧数据合同,新增独立 `target_weight_ratios` 计算预算。 +临界样例同时复现了第二个错误:原持仓按含买入滑点的价格重新估值,将未发生交易的滑点也扣进可买预算。补仓现在用目标市值减去原持仓行情市值,再按新买入价格和费用计算;卖出滑点只改变回款,不改变待减少的行情市值股数。 + 停牌持仓优先保留已记录的高精度比例;旧状态只有整数基点时保留已证明的旧预算,不反猜精确1/N。退出、候补、保护席位、指数仓位、资金预留和显式部分权重保留原规则。实际下单数量仍经过资金/费用、整手、T+1及风控检查。 执行状态新增 `last_target_weight_ratios`,首次记录精确比例升级schema2。旧schema1可读但不得携带新比例字段;旧消费者应拒绝新状态,回滚不能删除或降精度重写状态。回测、Paper、Live及Strategy Runtime都必须共同消费该比例,ETF顺延目标也携带相同比例。