修复补减仓时对原持仓重复计入交易滑点

This commit is contained in:
boris
2026-09-19 21:29:52 +08:00
parent 175b319d47
commit 4d731f9c14
3 changed files with 29 additions and 4 deletions
+12 -2
View File
@@ -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,
@@ -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 {
@@ -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顺延目标也携带相同比例。