Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ac9ee5058 |
@@ -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)],"es(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();
|
||||
|
||||
Reference in New Issue
Block a user