diff --git a/crates/fidc-core/src/broker_stock_pool.rs b/crates/fidc-core/src/broker_stock_pool.rs index 21b2294..1d054ea 100644 --- a/crates/fidc-core/src/broker_stock_pool.rs +++ b/crates/fidc-core/src/broker_stock_pool.rs @@ -343,6 +343,7 @@ impl BrokerSimulator { .map_err(BacktestError::Execution)?; constraints.pending_entry_symbols = execution_state.pending_symbols(); constraints.prior_target_weights = execution_state.last_target_weights.clone(); + constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation); constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date); let account = pool::AccountSnapshot { total_equity: contract.frozen_equity, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 07d1a9a..ff90a7f 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3666,6 +3666,8 @@ where let split_ratio = action.split_ratio(); if (split_ratio - 1.0).abs() > f64::EPSILON { + portfolio.adjust_stock_pool_split(&action.symbol, split_ratio) + .map_err(BacktestError::Execution)?; let (delta_quantity, quantity_after, average_cost) = { let position = portfolio .position_mut_if_exists(&action.symbol) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 51b6dc8..3b87c5a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -624,6 +624,7 @@ pub struct PlatformPositionTargetRule { pub when_expr: String, pub remaining_position_bps: u32, pub reason: String, + pub stock_pool_role: crate::stock_pool_execution::StockPoolExitRole, } #[derive(Debug, Clone)] @@ -10146,6 +10147,22 @@ impl PlatformExprStrategy { factor_date: NaiveDate, day: &DayExpressionState, ) -> Result, BacktestError> { + let mut targets = BTreeMap::new(); + for (_, scoped) in self.current_position_target_rules_by_role(ctx, signal_date, factor_date, day)? { + for (symbol, value) in scoped { + if targets.get(&symbol).is_none_or(|(bps, _)| value.0 < *bps) { targets.insert(symbol, value); } + } + } + Ok(targets) + } + + fn current_position_target_rules_by_role( + &self, + ctx: &StrategyContext<'_>, + signal_date: NaiveDate, + factor_date: NaiveDate, + day: &DayExpressionState, + ) -> Result>, BacktestError> { let mut targets = BTreeMap::new(); if self.config.position_target_rules.is_empty() { return Ok(targets); @@ -10160,11 +10177,12 @@ impl PlatformExprStrategy { if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? { continue; } - let replace = targets + let scoped = targets.entry(rule.stock_pool_role).or_insert_with(BTreeMap::new); + let replace = scoped .get(&position.symbol) .map_or(true, |(bps, _)| rule.remaining_position_bps < *bps); if replace { - targets.insert( + scoped.insert( position.symbol.clone(), (rule.remaining_position_bps, rule.reason.clone()), ); @@ -37826,6 +37844,7 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0; config.rebalance_existing_positions = true; config.hold_until_exit_enabled = true; config.position_target_rules = vec![PlatformPositionTargetRule { + stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell, when_expr: "factors[\"reduce_signal\"] == 1".to_string(), remaining_position_bps: 5_000, reason: "factor_reduce_position".to_string(), diff --git a/crates/fidc-core/src/platform_stock_pool.rs b/crates/fidc-core/src/platform_stock_pool.rs index f983d4e..964d5ea 100644 --- a/crates/fidc-core/src/platform_stock_pool.rs +++ b/crates/fidc-core/src/platform_stock_pool.rs @@ -14,6 +14,14 @@ impl PlatformExprStrategy { .as_ref() .ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))? .clone(); + if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty() + || self.config.position_target_rules.len() != program.exit_signals.len() + || self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)| + compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps + || compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role) + { + return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into())); + } let mut constraints = pool::stock_pool_constraints_from_configuration( &program.allocation_policy, &program.stop_take_policy, @@ -78,12 +86,11 @@ impl PlatformExprStrategy { closes, }); } - let rule = pool::normalize_stock_pool_execution_rule( + let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles( Some(&program.timing_policy), !self.config.buy_filter_expr.trim().is_empty(), - !self.config.stop_loss_expr.trim().is_empty() - || !self.config.take_profit_expr.trim().is_empty() - || !self.config.position_target_rules.is_empty(), + self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell), + self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit), ) .map_err(BacktestError::Execution)?; if self.config.in_skip_window(ctx.decision_date) { @@ -133,23 +140,13 @@ impl PlatformExprStrategy { } } } - let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?; - for symbol in native_exits { - constraints.position_target_bps.insert(symbol, 0); - } - for (symbol, (bps, _)) in - self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)? - { - constraints - .position_target_bps - .entry(symbol) - .and_modify(|old| *old = (*old).min(bps)) - .or_insert(bps); + for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? { + let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps }; + for (symbol, (bps, _)) in targets { output.insert(symbol, bps); } } let limit = constraints.target_holding_count.unwrap_or(ranked.len()); let final_symbols = ranked .iter() - .filter(|symbol| !constraints.position_target_bps.contains_key(*symbol)) .take(limit) .cloned() .collect(); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index ae1b060..c3d3a5d 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -2339,6 +2339,7 @@ pub fn platform_expr_config_from_spec( )); } cfg.position_target_rules.push(PlatformPositionTargetRule { + stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell, when_expr: when_expr.to_string(), remaining_position_bps: rule.remaining_position_bps, reason: rule @@ -2712,9 +2713,14 @@ pub fn platform_expr_config_from_spec( } if let Some(pool)=&spec.stock_pool { if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())} + let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty(); + if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); } let secondary_buy=!cfg.buy_filter_expr.trim().is_empty(); - let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty(); - pool.validate(secondary_buy,secondary_sell)?; + pool.validate(secondary_buy,false)?; + cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule { + when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps, + reason: signal.reason.clone(), stock_pool_role: signal.role, + })); cfg.stock_pool=Some(pool.clone()); cfg.hold_until_exit_enabled=false; cfg.daily_top_up_enabled=false; @@ -3456,6 +3462,7 @@ mod tests { assert_eq!( cfg.position_target_rules, vec![PlatformPositionTargetRule { + stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell, when_expr: "factors[\"reduce_signal\"] == 1".to_string(), remaining_position_bps: 5000, reason: "factor_reduce_position".to_string(), diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 5e552ce..2444a26 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -732,6 +732,16 @@ impl PortfolioState { state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(()) } + pub(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> { + let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string()) + .map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?; + let adjusted = self.stock_pool_states.iter() + .map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?))) + .collect::, String>>()?; + self.stock_pool_states = adjusted; + Ok(()) + } + pub fn initial_cash(&self) -> f64 { self.initial_cash.to_f64() } diff --git a/crates/fidc-core/src/stock_pool_execution.rs b/crates/fidc-core/src/stock_pool_execution.rs index 3505121..5f908d8 100644 --- a/crates/fidc-core/src/stock_pool_execution.rs +++ b/crates/fidc-core/src/stock_pool_execution.rs @@ -39,6 +39,22 @@ pub enum QuoteConditionScope { AnyTarget, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StockPoolExitRole { + OrdinarySell, + RiskExit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StockPoolExitSignal { + pub role: StockPoolExitRole, + pub when_expr: String, + pub remaining_position_bps: u32, + pub reason: String, +} + pub fn stock_pool_target_holding_count(policy: &Value) -> Result, String> { let object = policy .as_object() @@ -399,6 +415,8 @@ pub struct StockPoolExecutionRule { pub sell_condition_scope: Option, #[serde(skip)] pub secondary_sell_condition: bool, + #[serde(skip)] + pub independent_sell_condition: bool, #[serde( default, deserialize_with = "crate::holding_policy::deserialize_optional_policy" @@ -476,6 +494,10 @@ pub struct StockPoolDecisionConstraints { pub default_stop_loss: Option, pub default_take_profit: Option, pub position_target_bps: BTreeMap, + pub independent_position_target_bps: BTreeMap, + /// First actually planned holding quantity for this generation. Retries + /// apply percentages to this basis, never to the remaining holding. + pub position_action_bases: BTreeMap, pub buy_denials: BTreeMap>, pub same_day_sold_symbols: BTreeSet, pub automatic_permissions: BTreeMap, @@ -508,6 +530,8 @@ pub struct StockPoolPlanRow { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StockPoolPlan { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub position_action_bases: BTreeMap, pub market_timing: Option, pub rows: Vec, pub budget: Decimal, @@ -549,6 +573,8 @@ pub struct StockPoolProgram { pub timing_policy: Value, pub stop_take_policy: Value, pub out_of_pool_policy: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exit_signals: Vec, } impl StockPoolProgram { @@ -562,10 +588,19 @@ impl StockPoolProgram { normalize_stock_pool_members(&self.members)?; stock_pool_funding_from_configuration(&self.allocation_policy)?; stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?; - normalize_stock_pool_execution_rule( + let mut identities = BTreeSet::new(); + for signal in &self.exit_signals { + if signal.when_expr.trim().is_empty() || signal.reason.trim().is_empty() || signal.remaining_position_bps >= 10000 { + return Err("stock_pool_exit_signal_invalid".into()); + } + let identity = serde_json::to_string(signal).map_err(|error| error.to_string())?; + if !identities.insert(identity) { return Err("stock_pool_exit_signal_duplicate".into()); } + } + normalize_stock_pool_execution_rule_with_exit_roles( Some(&self.timing_policy), secondary_buy, - secondary_sell, + secondary_sell || self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::OrdinarySell), + self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::RiskExit), )?; if !matches!( self.out_of_pool_policy.as_str(), @@ -583,6 +618,7 @@ impl Default for StockPoolExecutionRule { buy_condition_scope: None, sell_condition_scope: None, secondary_sell_condition: false, + independent_sell_condition: false, automatic_trade_protection: Default::default(), schema_version: STOCK_POOL_SCHEMA_VERSION, auto_execute: true, @@ -671,12 +707,15 @@ 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 { + for (symbol, target) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) { 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, target) in &constraints.independent_position_target_bps { + effective_position_targets.entry(symbol.clone()).and_modify(|current| *current = (*current).min(*target)).or_insert(*target); + } for (symbol, permission) in &constraints.automatic_permissions { if permission.max_holding_exit { effective_position_targets.insert(symbol.clone(), 0); @@ -771,6 +810,19 @@ pub fn build_stock_pool_target_plan_with_fee_model( if quote_map.len() != quotes.len() { return Err("duplicate or invalid stock pool execution quotes".into()); } + let declared_symbols = normalized_members.iter().map(|member| member.symbol.as_str()).collect::>(); + for (symbol, quantity) in &constraints.position_action_bases { + if normalize_stock_symbol(symbol).as_ref() != Some(symbol) || *quantity <= Decimal::ZERO { + return Err(format!("invalid stock pool position-action basis:{symbol}")); + } + } + for (symbol, _) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) { + if normalize_stock_symbol(symbol).as_deref() != Some(symbol.as_str()) || (!declared_symbols.contains(symbol.as_str()) && !current.contains_key(symbol)) { + return Err(format!("position action is outside declared candidates and managed holdings:{symbol}")); + } + } + // Exit rules act on managed holdings, not on an unheld candidate's entry. + effective_position_targets.retain(|symbol, _| current.get(symbol).is_some_and(|position| position.0 > Decimal::ZERO)); frozen::validate(selection.trade_date, constraints, ¤t)?; for symbol in constraints.frozen_positions.keys() { effective_position_targets.remove(symbol); @@ -880,6 +932,7 @@ pub fn build_stock_pool_target_plan_with_fee_model( let mut quote_sell_exits = BTreeSet::new(); let mut sell_condition_denials = BTreeSet::new(); if rule.sell_trigger_mode == POOL_SELL_CONDITION { + let ordinary_enabled = !rule.sell_condition.trim().is_empty() || rule.secondary_sell_condition; // Ordinary sell predicates only depend on positions participating in // that stage. Independent stops/expiry and protected holdings were // already decided above; unrelated quote fields must not block them. @@ -889,24 +942,29 @@ pub fn build_stock_pool_target_plan_with_fee_model( row.0 > Decimal::ZERO && !protected_positions.contains(*symbol) && !global_stop_hits.contains(*symbol) + && constraints.independent_position_target_bps.get(*symbol) != Some(&0) && !constraints.automatic_permissions.get(*symbol) .is_some_and(|permission| permission.max_holding_exit) }) .map(|(symbol, _)| symbol.clone()) .collect::>(); - let qualified = quote_condition_results( + let qualified = if ordinary_enabled { quote_condition_results( &rule.sell_condition, rule.sell_condition_scope, &held, "e_map, - )?; + )? } else { BTreeMap::new() }; for symbol in held { - let permitted = qualified.get(&symbol) == Some(&true) + let permitted = ordinary_enabled && qualified.get(&symbol) == Some(&true) && (!rule.secondary_sell_condition || constraints.position_target_bps.contains_key(&symbol)); if !permitted { - sell_condition_denials.insert(symbol.clone()); effective_position_targets.remove(&symbol); + if let Some(target) = constraints.independent_position_target_bps.get(&symbol) { + effective_position_targets.insert(symbol.clone(), *target); + } else { + sell_condition_denials.insert(symbol.clone()); + } } else if !rule.secondary_sell_condition { quote_sell_exits.insert(symbol.clone()); effective_position_targets.insert(symbol, 0); @@ -957,6 +1015,7 @@ pub fn build_stock_pool_target_plan_with_fee_model( let normalized_same_day_sold = normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::>())?; let mut rebuy_exclusions = stop_take_exits.clone(); + rebuy_exclusions.extend(effective_position_targets.keys().cloned()); rebuy_exclusions.extend( normalized_same_day_sold .iter() @@ -1346,14 +1405,6 @@ pub fn build_stock_pool_target_plan_with_fee_model( "factor position-action symbol {symbol} is outside candidates and managed holdings" )); } - if selection.final_symbols.contains(symbol) - && !maximum_holding_exits.contains(symbol) - && !quote_sell_exits.contains(symbol) - { - return Err(format!( - "factor position-action symbol {symbol} cannot remain in final selection" - )); - } let current_quantity = current .get(symbol) .map(|value| value.0) @@ -1375,10 +1426,11 @@ pub fn build_stock_pool_target_plan_with_fee_model( Decimal::ZERO } else { floor_step( - current_quantity * Decimal::from(*target_bps) / Decimal::from(10_000), + constraints.position_action_bases.get(symbol).copied().unwrap_or(current_quantity) + * Decimal::from(*target_bps) / Decimal::from(10_000), step, ) - }; + }.min(current_quantity); let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO); let executable = if *target_bps == 0 { closable_quantity.min(current_quantity).max(Decimal::ZERO) @@ -1392,13 +1444,33 @@ pub fn build_stock_pool_target_plan_with_fee_model( if current_quantity == Decimal::ZERO { ( "FACTOR_EXIT_ALREADY_SATISFIED", - "生产因子持仓动作命中,当前无持仓", + "持仓退出规则命中,当前无持仓", Decimal::ZERO, Decimal::ZERO, None, None, None, ) + } else if desired_reduction == Decimal::ZERO { + ( + "FACTOR_EXIT_ALREADY_SATISFIED", + "本次信号的持仓退出目标已达到,不重复减仓", + Decimal::ZERO, + current_quantity, + None, + None, + None, + ) + } else if executable == Decimal::ZERO && closable_quantity >= desired_reduction { + ( + "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED", + "目标持仓差额不足最小交易单位,无需重复委托", + Decimal::ZERO, + current_quantity, + None, + None, + None, + ) } else if executable == Decimal::ZERO { ( "DEFERRED_T_PLUS_ONE", @@ -1429,6 +1501,8 @@ pub fn build_stock_pool_target_plan_with_fee_model( "卖出行情条件命中" } else if stop_take_exits.contains(symbol) { "止损/止盈触发,覆盖较弱的减仓目标" + } else if constraints.independent_position_target_bps.get(symbol) == Some(target_bps) { + "独立风险退出条件命中" } else if *target_bps == 0 { "生产因子退出条件命中" } else { @@ -1913,7 +1987,15 @@ pub fn build_stock_pool_target_plan_with_fee_model( .into_iter() .sum(); let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount; + let position_action_bases = rows.iter() + .filter(|row| effective_position_targets.get(&row.symbol).is_some_and(|bps| *bps > 0) + && row.current_quantity > Decimal::ZERO + && row.status != "AUTOMATIC_TRADE_PROTECTED" + && !constraints.frozen_positions.contains_key(&row.symbol)) + .map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity))) + .collect(); Ok(StockPoolPlan { + position_action_bases, market_timing, rows, budget, @@ -2190,6 +2272,15 @@ pub fn normalize_stock_pool_execution_rule( raw: Option<&Value>, secondary_buy_condition: bool, secondary_sell_condition: bool, +) -> Result { + normalize_stock_pool_execution_rule_with_exit_roles(raw, secondary_buy_condition, secondary_sell_condition, false) +} + +pub fn normalize_stock_pool_execution_rule_with_exit_roles( + raw: Option<&Value>, + secondary_buy_condition: bool, + secondary_sell_condition: bool, + independent_sell_condition: bool, ) -> Result { let mut rule = match raw { None | Some(Value::Null) => StockPoolExecutionRule::default(), @@ -2197,6 +2288,7 @@ pub fn normalize_stock_pool_execution_rule( .map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?, }; rule.secondary_sell_condition = secondary_sell_condition; + rule.independent_sell_condition = independent_sell_condition; rule.automatic_trade_protection.validate()?; if rule.schema_version != STOCK_POOL_SCHEMA_VERSION { return Err(format!( @@ -2283,7 +2375,7 @@ pub fn normalize_stock_pool_execution_rule( return Err("stock pool buy_condition is not supported".to_string()); } if rule.sell_trigger_mode == POOL_SELL_CONDITION { - if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition) + if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition && !independent_sell_condition) || (!rule.sell_condition.trim().is_empty() && parse_stock_pool_condition(&rule.sell_condition).is_none()) { diff --git a/crates/fidc-core/src/stock_pool_execution_tests.rs b/crates/fidc-core/src/stock_pool_execution_tests.rs index 0efc74a..7084023 100644 --- a/crates/fidc-core/src/stock_pool_execution_tests.rs +++ b/crates/fidc-core/src/stock_pool_execution_tests.rs @@ -870,6 +870,64 @@ fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate() } } +#[test] +fn typed_exit_roles_merge_only_satisfied_ordinary_actions_with_independent_risk() { + for risk in [None,Some(0),Some(5000)] { + for ordinary in [None,Some(0),Some(7500)] { + for quote in ["","price<9","price>9"] { + for locked in [false,true] { + for closable in [0,400,1000] { + let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":quote})),false,true,true).unwrap(); + let mut constraints=StockPoolDecisionConstraints {portfolio_policy:Some(StockPoolPortfolioPolicy{schema_version:1,membership:MembershipPolicy::RetainHoldings,rebalance_weights:false}),..Default::default()}; + if let Some(target)=ordinary {constraints.position_target_bps.insert(symbol(1),target);} + if let Some(target)=risk {constraints.independent_position_target_bps.insert(symbol(1),target);} + 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 mut held=position(1);held.closable_quantity=closable.into(); + let plan=condition_plan(&selection(1,1),&rule,&[held],"es(1),&constraints); + assert_eq!(plan.rows.len(),1,"{risk:?}/{ordinary:?}/{quote}: {plan:?}"); + let ordinary=if quote=="price<9" {None} else {ordinary}; + let target_bps=risk.into_iter().chain(ordinary).min().unwrap_or(10000); + let desired=if target_bps==0 {0} else {(1000*target_bps/10000)/100*100}; + let sold=if locked {0} else {(1000-desired).min(closable)}; + assert_eq!(plan.rows[0].delta_quantity,-Decimal::from(sold),"{risk:?}/{ordinary:?}/{quote}: {plan:?}"); + } + } + } + } + } +} + +#[test] +fn risk_only_configuration_never_turns_into_an_unconditional_ordinary_exit() { + let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition"})),false,false,true).unwrap(); + let mut constraints=StockPoolDecisionConstraints::default(); + let hold=condition_plan(&selection(1,1),&rule,&[position(1)],"es(1),&constraints); + assert_ne!(hold.rows[0].side,Some(OrderSide::Sell),"a risk-only configuration must not manufacture an exit: {hold:?}"); + constraints.independent_position_target_bps.insert(symbol(1),5000); + let exit=condition_plan(&selection(1,1),&rule,&[position(1)],"es(1),&constraints); + assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}"); + let unheld=condition_plan(&selection(1,1),&rule,&[],"es(1),&constraints); + assert_eq!(unheld.rows[0].side,Some(OrderSide::Buy),"an exit-only rule must not secretly become a selection/buy filter: {unheld:?}"); +} + +#[test] +fn quote_only_exit_still_works_when_independent_risk_rules_are_configured() { + let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>9"})),false,false,true).unwrap(); + let plan=condition_plan(&selection(1,1),&rule,&[position(1)],"es(1),&StockPoolDecisionConstraints::default()); + assert_eq!(plan.rows.len(),1);assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}"); +} + +#[test] +fn independent_full_exit_has_no_ordinary_quote_dependency_but_partial_risk_does_not_fake_missing_facts() { + let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>100"})),false,true,true).unwrap(); + let mut market=quotes(1);market[0].volume=None; + let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),0)]),independent_position_target_bps:BTreeMap::from([(symbol(1),0)]),..Default::default()}; + let complete=condition_plan(&selection(1,1),&rule,&[position(1)],&market,&constraints); + assert_eq!(complete.rows[0].delta_quantity,Decimal::from(-1000)); + constraints.independent_position_target_bps.insert(symbol(1),5000); + assert!(condition_plan_result(&selection(1,1),&rule,&[position(1)],&market,&constraints).unwrap_err().contains("requires volume")); +} + #[test] fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() { let mut constraints = StockPoolDecisionConstraints::default(); diff --git a/crates/fidc-core/src/stock_pool_state.rs b/crates/fidc-core/src/stock_pool_state.rs index 2da624e..ed0456a 100644 --- a/crates/fidc-core/src/stock_pool_state.rs +++ b/crates/fidc-core/src/stock_pool_state.rs @@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress { pub completion_quantity: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StockPoolPositionActionBasis { + pub generation: String, + pub first_execution_date: NaiveDate, + pub quantity: Decimal, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolExecutionState { @@ -34,6 +42,10 @@ pub struct StockPoolExecutionState { pub last_target_weights: BTreeMap, /// First signal excluding an actually held member; not an acquisition date. pub removed_since: BTreeMap, + /// Signal progress, not a fill or holding-period fact. Kept across retries + /// and later execution sessions until a new generation supersedes it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub position_action_bases: BTreeMap, } pub struct StockPoolGoalObservation<'a> { @@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState { entries: BTreeMap::new(), last_target_weights: BTreeMap::new(), removed_since: BTreeMap::new(), + position_action_bases: BTreeMap::new(), } } } @@ -62,6 +75,7 @@ impl StockPoolExecutionState { if self.schema_version != 1 || self.entries.len() > 10000 || self.removed_since.len() > 10000 + || self.position_action_bases.len() > 10000 { return Err("stock_pool_execution_state_invalid_schema_or_size".into()); } @@ -70,6 +84,7 @@ impl StockPoolExecutionState { .keys() .chain(self.removed_since.keys()) .chain(self.last_target_weights.keys()) + .chain(self.position_action_bases.keys()) { if normalize_stock_symbol(symbol).as_ref() != Some(symbol) { return Err("stock_pool_execution_state_invalid_symbol".into()); @@ -97,6 +112,12 @@ impl StockPoolExecutionState { { return Err("stock_pool_execution_state_invalid_goal_or_clock".into()); } + if self.position_action_bases.values().any(|basis| { + basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO + || self.last_execution_date.is_none_or(|date| basis.first_execution_date > date) + }) { + return Err("stock_pool_execution_state_invalid_action_basis".into()); + } Ok(()) } @@ -186,7 +207,7 @@ impl StockPoolExecutionState { self.record_targets( decision_date, generation, - plan.rows.iter().map(|row| StockPoolGoalObservation { + plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation { symbol: &row.symbol, target_weight_bps: row.target_weight_bps, target_value: row.target_value, @@ -194,7 +215,67 @@ impl StockPoolExecutionState { target_quantity: row.target_quantity, status: &row.status, }), - ) + )?.record_position_action_bases(generation, &plan.position_action_bases) + } + + pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap { + self.position_action_bases.iter() + .filter(|(_, basis)| basis.generation == generation) + .map(|(symbol, basis)| (symbol.clone(), basis.quantity)) + .collect() + } + + /// A verified split changes the share unit, not the intended reduction or + /// entry completion. Never infer a split from a changed holding quantity. + pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result { + self.validate()?; + if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) { + return Err("stock_pool_execution_state_split_invalid".into()); + } + let scale = |quantity: Decimal| quantity.checked_mul(ratio) + .map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero)) + .ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string()); + let mut next = self.clone(); + if let Some(entry) = next.entries.get_mut(symbol) { + if let Some(quantity) = entry.completion_quantity { + let quantity = scale(quantity)?; + entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity); + } + } + if let Some(basis) = next.position_action_bases.get_mut(symbol) { + basis.quantity = scale(basis.quantity)?; + if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); } + } + next.validate()?; + Ok(next) + } + + pub fn record_position_action_bases( + &self, + generation: &str, + quantities: &BTreeMap, + ) -> Result { + self.validate()?; + if generation.trim().is_empty() { + return Err("stock_pool_execution_state_action_generation_missing".into()); + } + let first_execution_date = self.last_execution_date + .ok_or("stock_pool_execution_state_action_clock_missing")?; + let mut next = self.clone(); + next.position_action_bases.retain(|_, basis| basis.generation == generation); + for (symbol, quantity) in quantities { + if let Some(basis) = next.position_action_bases.get(symbol) { + if basis.quantity != *quantity { + return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}")); + } + } else { + next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis { + generation: generation.into(), first_execution_date, quantity: *quantity, + }); + } + } + next.validate()?; + Ok(next) } pub fn record_targets<'a>( @@ -213,6 +294,9 @@ impl StockPoolExecutionState { } let mut next = self.clone(); for row in rows { + if row.status == "AUTOMATIC_TRADE_PROTECTED" { + continue; + } if row.target_weight_bps > 0 { next.last_target_weights .insert(row.symbol.into(), row.target_weight_bps); diff --git a/crates/fidc-core/tests/stock_pool_execution_contract.rs b/crates/fidc-core/tests/stock_pool_execution_contract.rs index a1cc510..a831790 100644 --- a/crates/fidc-core/tests/stock_pool_execution_contract.rs +++ b/crates/fidc-core/tests/stock_pool_execution_contract.rs @@ -470,11 +470,31 @@ fn ordinary_sell_has_one_order_owner_before_broker_execution() { assert_eq!(report.account_events[1].cash_before,40000.); } +#[test] +fn repeating_the_same_partial_exit_generation_does_not_reduce_again() { + let data=data(false);let broker=broker(false);let mut account=PortfolioState::new(20000.); + account.position_mut(&code(1)).buy(day(2),1000,10.); + let mut intent=contract(day(2),1,true); + intent.constraints.independent_position_target_bps.insert(code(1),5000); + let first=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap(); + assert_eq!(first.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::(),500); + let repeated=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap(); + assert!(repeated.fill_events.iter().all(|fill|fill.symbol!=code(1)),"same generation must keep its first partial-exit target: {repeated:?}"); + assert_eq!(account.position(&code(1)).unwrap().quantity,500); + let next_day=broker.execute_with_event_dates(day(6),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap(); + assert!(next_day.fill_events.iter().all(|fill|fill.symbol!=code(1)),"{next_day:?}"); + assert_eq!(account.position(&code(1)).unwrap().quantity,500); + intent.generation="a-new-reduction-signal".into(); + let new_signal=broker.execute_with_event_dates(day(6),day(6),day(6),&mut account,&data,&decision(intent)).unwrap(); + assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::(),300); +} + #[test] fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() { let intent = contract(day(2), 1, false); for quote_condition in ["", "price<5"] { let program = StockPoolProgram { + exit_signals: vec![], schema_version: 1, pool_id: "pool-fixture".into(), version_id: "version-fixture".into(), @@ -541,6 +561,34 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat } } +#[test] +fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() { + for (ordinary, risk, quote, sold) in [ + (Some(0),None,"price<1",0), + (None,Some(0),"price<1",3000), + (Some(0),Some(5000),"price<1",1500), + (Some(0),Some(5000),"price>1",3000), + (None,Some(5000),"",1500), + ] { + let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()}) + .chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::>(); + let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members, + allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}), + timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}), + stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits}; + let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap(); + config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into(); + config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1)); + config.matching_type=MatchingType::CurrentBarClose; + let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose),BacktestConfig{ + initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close, + }).run().unwrap(); + assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::(),3000,"exit-only criteria must not suppress a new entry: {result:?}"); + let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::(); + assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}"); + } +} + #[test] fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() { // Generated by OmniQuant's actual handoff and compiler, not a hand-written @@ -764,6 +812,7 @@ fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() { let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap(); let intent=contract(day(2),1,true); let program=StockPoolProgram { + exit_signals: vec![], schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members, allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}), timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}), @@ -798,7 +847,7 @@ fn etf_signal_budget_does_not_read_the_current_sessions_future_close() { if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);} } let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap(); - let program=StockPoolProgram{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members, + let program=StockPoolProgram{exit_signals:vec![],schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members, allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}), timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()}; let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap(); diff --git a/crates/fidc-core/tests/stock_pool_execution_state.rs b/crates/fidc-core/tests/stock_pool_execution_state.rs index 978044c..615264a 100644 --- a/crates/fidc-core/tests/stock_pool_execution_state.rs +++ b/crates/fidc-core/tests/stock_pool_execution_state.rs @@ -158,6 +158,42 @@ fn legacy_state_without_quantity_keeps_its_serialized_identity() { assert_eq!(serde_json::to_value(state).unwrap(), original); } +#[test] +fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() { + let original = StockPoolExecutionState::default() + .observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap(); + let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]); + let saved = original.record_position_action_bases("sell-signal", &basis).unwrap(); + assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input"); + let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap(); + let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap(); + assert_eq!(next_day.position_action_bases_for("sell-signal"), basis); + assert!(next_day.position_action_bases_for("new-signal").is_empty()); + assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed")); + let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap(); + assert!(new_signal.position_action_bases_for("sell-signal").is_empty()); + assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500)); + for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] { + assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err()); + } + assert!(original.record_position_action_bases(" ", &basis).is_err()); +} + +#[test] +fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() { + let initial = StockPoolExecutionState::default() + .observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap(); + let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold"); + let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap(); + let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap(); + let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap(); + assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500)); + assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500))); + assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11)); + assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000)); + assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err()); +} + #[test] fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() { let members = vec![member()];