From 4ac9ee5058b280dcc4acc792c3129ecc35c647e6 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 12 Sep 2026 14:21:33 +0800 Subject: [PATCH] fix(stock-pool): keep full stops stronger than simultaneous reductions --- crates/fidc-core/src/stock_pool_execution.rs | 21 ++++++-- .../src/stock_pool_execution_tests.rs | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/fidc-core/src/stock_pool_execution.rs b/crates/fidc-core/src/stock_pool_execution.rs index a07a622..3505121 100644 --- a/crates/fidc-core/src/stock_pool_execution.rs +++ b/crates/fidc-core/src/stock_pool_execution.rs @@ -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::>(); + // 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 { diff --git a/crates/fidc-core/src/stock_pool_execution_tests.rs b/crates/fidc-core/src/stock_pool_execution_tests.rs index 2a91837..0efc74a 100644 --- a/crates/fidc-core/src/stock_pool_execution_tests.rs +++ b/crates/fidc-core/src/stock_pool_execution_tests.rs @@ -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::>(); + 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::>(); + 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();