修复补减仓时对原持仓重复计入交易滑点
This commit is contained in:
@@ -1588,7 +1588,17 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
if sizing_price <= Decimal::ZERO {
|
if sizing_price <= Decimal::ZERO {
|
||||||
return Err(format!("{symbol} execution sizing price is invalid"));
|
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 (step, minimum_buy) = order_quantity_rules(quote)?;
|
||||||
let mut target_quantity = current_quantity;
|
let mut target_quantity = current_quantity;
|
||||||
let mut delta = Decimal::ZERO;
|
let mut delta = Decimal::ZERO;
|
||||||
@@ -1911,7 +1921,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
let cost = |quantity: Decimal| {
|
let cost = |quantity: Decimal| {
|
||||||
Ok(quantity * price + fee_for(&row.symbol, OrderSide::Buy, quantity * price)?)
|
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(
|
let allocation_quantity = max_affordable_buy_quantity_with_cost(
|
||||||
own_budget,
|
own_budget,
|
||||||
row.delta_quantity,
|
row.delta_quantity,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use super::*;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn equal_thirty_seats_use_full_precision_at_a_board_lot_boundary() {
|
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 pool = members(30);
|
||||||
let mut market = quotes(30);
|
let mut market = quotes(30);
|
||||||
let last = market.last_mut().unwrap();
|
let last = market.last_mut().unwrap();
|
||||||
last.last_price = price.parse().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 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 mut selection = selection(30, 30);
|
||||||
let constraints = StockPoolDecisionConstraints { target_holding_count: Some(30), reserve_cash_slots: 1, ..Default::default() };
|
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);
|
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;
|
use serde_json::json;
|
||||||
|
|
||||||
fn symbol(index: usize) -> String {
|
fn symbol(index: usize) -> String {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
此前按10000整数基点分配等权,然后反算资金。30只股票的333/334基点并不等于1/30,在临界整手处会漏补仓。修复将 `target_weight_bps` 保留为展示/旧数据合同,新增独立 `target_weight_ratios` 计算预算。
|
此前按10000整数基点分配等权,然后反算资金。30只股票的333/334基点并不等于1/30,在临界整手处会漏补仓。修复将 `target_weight_bps` 保留为展示/旧数据合同,新增独立 `target_weight_ratios` 计算预算。
|
||||||
|
|
||||||
|
临界样例同时复现了第二个错误:原持仓按含买入滑点的价格重新估值,将未发生交易的滑点也扣进可买预算。补仓现在用目标市值减去原持仓行情市值,再按新买入价格和费用计算;卖出滑点只改变回款,不改变待减少的行情市值股数。
|
||||||
|
|
||||||
停牌持仓优先保留已记录的高精度比例;旧状态只有整数基点时保留已证明的旧预算,不反猜精确1/N。退出、候补、保护席位、指数仓位、资金预留和显式部分权重保留原规则。实际下单数量仍经过资金/费用、整手、T+1及风控检查。
|
停牌持仓优先保留已记录的高精度比例;旧状态只有整数基点时保留已证明的旧预算,不反猜精确1/N。退出、候补、保护席位、指数仓位、资金预留和显式部分权重保留原规则。实际下单数量仍经过资金/费用、整手、T+1及风控检查。
|
||||||
|
|
||||||
执行状态新增 `last_target_weight_ratios`,首次记录精确比例升级schema2。旧schema1可读但不得携带新比例字段;旧消费者应拒绝新状态,回滚不能删除或降精度重写状态。回测、Paper、Live及Strategy Runtime都必须共同消费该比例,ETF顺延目标也携带相同比例。
|
执行状态新增 `last_target_weight_ratios`,首次记录精确比例升级schema2。旧schema1可读但不得携带新比例字段;旧消费者应拒绝新状态,回滚不能删除或降精度重写状态。回测、Paper、Live及Strategy Runtime都必须共同消费该比例,ETF顺延目标也携带相同比例。
|
||||||
|
|||||||
Reference in New Issue
Block a user