fix(stock-pool): keep full stops stronger than simultaneous reductions

This commit is contained in:
boris
2026-09-12 14:21:33 +08:00
parent 848c1a514a
commit 4ac9ee5058
2 changed files with 64 additions and 5 deletions
+16 -5
View File
@@ -669,6 +669,13 @@ pub fn build_stock_pool_target_plan_with_fee_model(
}
}
}
// Validate source targets before a stronger stop/expiry can replace them.
// Otherwise an invalid ratio could be hidden by target consolidation.
for (symbol, target) in &constraints.position_target_bps {
if *target >= 10_000 {
return Err(format!("factor position target for {symbol} must be below 10000 bps"));
}
}
let mut effective_position_targets = constraints.position_target_bps.clone();
for (symbol, permission) in &constraints.automatic_permissions {
if permission.max_holding_exit {
@@ -863,6 +870,13 @@ pub fn build_stock_pool_target_plan_with_fee_model(
.then(|| symbol.clone())
})
.collect::<BTreeSet<_>>();
// A full stop is stricter than a simultaneous relative reduction. Merge
// the target before selecting its single owner, never emit a second exit.
for symbol in &global_stop_hits {
if let Some(target) = effective_position_targets.get_mut(symbol) {
*target = 0;
}
}
let mut quote_sell_exits = BTreeSet::new();
let mut sell_condition_denials = BTreeSet::new();
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
@@ -1327,11 +1341,6 @@ pub fn build_stock_pool_target_plan_with_fee_model(
}
for (symbol, target_bps) in factor_position_target_bps {
if *target_bps >= 10_000 {
return Err(format!(
"factor position target for {symbol} must be below 10000 bps"
));
}
if !member_map.contains_key(symbol) && !current.contains_key(symbol) {
return Err(format!(
"factor position-action symbol {symbol} is outside candidates and managed holdings"
@@ -1418,6 +1427,8 @@ pub fn build_stock_pool_target_plan_with_fee_model(
"达到最长持有期,按配置退出"
} else if quote_sell_exits.contains(symbol) {
"卖出行情条件命中"
} else if stop_take_exits.contains(symbol) {
"止损/止盈触发,覆盖较弱的减仓目标"
} else if *target_bps == 0 {
"生产因子退出条件命中"
} else {
@@ -783,6 +783,54 @@ fn independent_exit_quote_priority_does_not_bypass_t_plus_one_or_price_validatio
assert!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err().contains("execution quote is invalid"));
}
#[test]
fn full_stop_overrides_a_simultaneous_factor_reduction_without_a_second_target() {
let mut selected = selection(2, 1);
selected.final_symbols = vec![symbol(2)];
let mut market = quotes(2);
market[0].last_price = 9.into();
let constraints = StockPoolDecisionConstraints {
default_stop_loss: Some(Decimal::new(5, 2)),
position_target_bps: BTreeMap::from([(symbol(1), 5000)]),
..Default::default()
};
let plan = condition_plan(&selected, &StockPoolExecutionRule::default(), &[position(1)], &market, &constraints);
let rows = plan.rows.iter().filter(|row|row.symbol==symbol(1)).collect::<Vec<_>>();
assert_eq!(rows.len(),1,"{plan:?}");
assert_eq!(rows[0].target_quantity,Decimal::ZERO,"a full stop must not be weakened by a 50% reduction: {plan:?}");
assert_eq!(rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn stop_reduction_merge_matrix_preserves_protection_t_plus_one_and_invalid_config_errors() {
for take_profit in [false,true] {
for reduction in [0,2500,5000,9999] {
for closable in [0,400,1000] {
for locked in [false,true] {
let mut selected=selection(2,1);selected.final_symbols=vec![symbol(2)];
let mut market=quotes(2);market[0].last_price=if take_profit {12.into()} else {9.into()};market[0].volume=None;
let mut held=position(1);held.closable_quantity=Decimal::from(closable);
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),reduction)]),..Default::default()};
if take_profit {constraints.default_take_profit=Some(Decimal::new(10,2))} else {constraints.default_stop_loss=Some(Decimal::new(5,2))}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission {sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let rule=normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),false,true).unwrap();
let plan=condition_plan(&selected,&rule,&[held],&market,&constraints);
let rows=plan.rows.iter().filter(|row|row.symbol==symbol(1)).collect::<Vec<_>>();
assert_eq!(rows.len(),1,"{plan:?}");
let sold=if locked {0} else {closable};
assert_eq!(rows[0].delta_quantity,-Decimal::from(sold),"{plan:?}");
assert_eq!(rows[0].target_quantity,Decimal::from(1000-sold),"{plan:?}");
assert_eq!(plan.estimated_sell_amount,Decimal::from(sold)*market[0].last_price,"{plan:?}");
if locked {assert_eq!(rows[0].status,"AUTOMATIC_TRADE_PROTECTED","{plan:?}")}
}
}
}
}
let mut invalid=StockPoolDecisionConstraints {default_stop_loss:Some(Decimal::new(5,2)),position_target_bps:BTreeMap::from([(symbol(1),10000)]),..Default::default()};
invalid.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission {max_holding_exit:true,..Default::default()});
assert!(condition_plan_result(&selection(1,1),&StockPoolExecutionRule::default(),&[position(1)],&quotes(1),&invalid).unwrap_err().contains("must be below 10000"));
}
#[test]
fn ordinary_sell_keeps_required_quote_failures_and_zero_stop_is_not_an_exit() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();