//! Deterministic stock-pool target planning shared by historical and online execution. //! Account, quote and fill facts are supplied by the caller; this module has no I/O. use chrono::NaiveDate; use rust_decimal::{Decimal, RoundingStrategy}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashMap}; pub const STOCK_POOL_SCHEMA_VERSION: u32 = 1; pub const POOL_TRIGGER_FIRST_TICK: &str = "first_tick_after_open"; pub const POOL_TRIGGER_TIME_WINDOW: &str = "time_window"; pub const POOL_TRIGGER_CONDITION: &str = "condition"; pub const POOL_TRIGGER_SCHEDULED_BAR: &str = "scheduled_bar"; pub const POOL_PRICE_FIRST_TICK: &str = "first_tick"; pub const POOL_PRICE_OPENING_AUCTION: &str = "opening_auction"; pub const POOL_PRICE_FIXED_LIMIT: &str = "fixed_limit"; pub const POOL_PRICE_FORMULA_LIMIT: &str = "formula_limit"; pub const POOL_PRICE_CONDITION_THEN_LIMIT: &str = "condition_then_limit"; pub const POOL_PRICE_CONDITION_THEN_MARKET: &str = "condition_then_market"; pub const POOL_SELL_TARGET_DELTA: &str = "target_delta"; pub const POOL_SELL_CONDITION: &str = "condition"; pub const STOCK_POOL_CURRENT_SNAPSHOT_TOLERANCE_SECONDS: u64 = 120; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrderSide { Buy, Sell, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum QuoteConditionScope { #[default] PerSymbol, AllTargets, 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() .ok_or("allocation_policy must be an object")?; let mut observed: Option> = None; for key in ["target_holding_count", "targetHoldingCount"] { let Some(raw) = object.get(key) else { continue }; let count = if raw.is_null() || raw.as_str().is_some_and(|value| value.trim().is_empty()) { None } else { let text = match raw { Value::String(value) => value.trim().to_owned(), Value::Number(value) => value.to_string(), _ => return Err("allocation_policy.target_holding_count must be an integer".into()), }; let value = text .parse::() .map_err(|_| "allocation_policy.target_holding_count must be an integer")?; if value.fract() != Decimal::ZERO || value < Decimal::ONE || value > Decimal::from(10000) { return Err( "allocation_policy.target_holding_count must be between 1 and 10000".into(), ); } Some( value .normalize() .to_string() .parse::() .map_err(|_| "allocation_policy.target_holding_count must be an integer")?, ) }; if observed.is_some_and(|previous| previous != count) { return Err("allocation_policy.target_holding_count aliases conflict".into()); } observed = Some(count); } Ok(observed.flatten()) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum MembershipPolicy { #[default] FollowCandidates, RetainHoldings, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolPortfolioPolicy { pub schema_version: u32, pub membership: MembershipPolicy, pub rebalance_weights: bool, } impl StockPoolPortfolioPolicy { pub fn from_legacy(value: &str) -> Result { let (membership, rebalance_weights) = match value { "full_rebalance" => (MembershipPolicy::FollowCandidates, true), "preserve_existing" => (MembershipPolicy::FollowCandidates, false), "preserve_members" => (MembershipPolicy::RetainHoldings, true), _ => return Err(format!("unsupported top_n_rebalance_policy={value}")), }; Ok(Self { schema_version: 1, membership, rebalance_weights, }) } pub fn validate(&self) -> Result<(), String> { if self.schema_version != 1 { return Err("stock pool portfolio policy schema_version must be 1".into()); } Ok(()) } } pub fn stock_pool_funding_from_configuration(allocation: &Value) -> Result<(i32, Decimal), String> { let empty = serde_json::Map::new(); let object = if allocation.is_null() { &empty } else { allocation .as_object() .ok_or("allocation_policy must be an object")? }; let number = |keys: &[&str]| -> Result, String> { let mut result: Option> = None; for key in keys { if let Some(value) = object.get(*key) { let next = if value.is_null() || value.as_str().is_some_and(|v| v.trim().is_empty()) { None } else { let raw = match value { Value::Number(n) => n.to_string(), Value::String(v) => v.trim().to_owned(), _ => return Err(format!("invalid numeric allocation field:{key}")), }; Some( raw.parse::() .map_err(|_| format!("invalid numeric allocation field:{key}"))?, ) }; if result.is_some_and(|old| old != next) { return Err(format!("allocation aliases conflict:{}", keys[0])); } result = Some(next); } } Ok(result.flatten()) }; let basis = number(&["invest_ratio_bps", "investRatioBps"])?; let fraction = number(&["invest_ratio", "investRatio"])?; if fraction.is_some_and(|v| v < Decimal::ZERO || v > Decimal::ONE) { return Err("invest_ratio must be in [0,1]; use invest_ratio_bps for basis points".into()); } let normalized_fraction = fraction.map(|value| value * Decimal::from(10000)); if let (Some(left), Some(right)) = (basis, normalized_fraction) { if left != right { return Err("investment ratio aliases conflict".into()); } } let ratio = basis .or(normalized_fraction) .unwrap_or(Decimal::from(10000)); if ratio < Decimal::ZERO || ratio > Decimal::from(10000) || ratio.fract() != Decimal::ZERO { return Err("investment ratio must be an integer between 0 and 10000 basis points".into()); } let cash = number(&["reserve_cash", "reserveCash"])?.unwrap_or(Decimal::ZERO); if cash < Decimal::ZERO { return Err("reserve_cash must be nonnegative".into()); } Ok(( ratio .normalize() .to_string() .parse::() .map_err(|_| "investment ratio out of range")?, cash, )) } pub fn stock_pool_constraints_from_configuration( allocation: &Value, stops: &Value, ) -> Result { stock_pool_funding_from_configuration(allocation)?; let empty = serde_json::Map::new(); let object = if allocation.is_null() { &empty } else { allocation .as_object() .ok_or("allocation_policy must be an object")? }; let alias = |keys: &[&str]| -> Result, String> { let mut value = None; for key in keys { if let Some(next) = object.get(*key) { if value.is_some_and(|old| old != next) { return Err(format!("allocation aliases conflict:{}", keys[0])); } value = Some(next) } } Ok(value) }; let legacy = alias(&["top_n_rebalance_policy", "topNRebalancePolicy"])?; if legacy.is_some_and(|value| !value.is_null() && !value.is_string()) { return Err("legacy rebalance policy must be a string".into()); } if !stops.is_null() && !stops.is_object() { return Err("stop_take_policy must be an object".into()); } let legacy_policy = StockPoolPortfolioPolicy::from_legacy( legacy.and_then(Value::as_str).unwrap_or("full_rebalance"), )?; let policy = if let Some(raw) = alias(&["portfolio_policy", "portfolioPolicy"])?.filter(|raw| !raw.is_null()) { let value: StockPoolPortfolioPolicy = serde_json::from_value(raw.clone()) .map_err(|error| format!("invalid stock pool portfolio policy:{error}"))?; value.validate()?; if legacy.is_some_and(|value| !value.is_null()) && value != legacy_policy { return Err("portfolio policy conflicts with legacy rebalance policy".into()); } value } else { legacy_policy }; let target_holding_count = if allocation.is_null() { None } else { stock_pool_target_holding_count(allocation)? }; let reserve_cash_slots = match alias(&["reserve_cash_slots", "reserveCashSlots"])? .filter(|value| !value.is_null()) { Some(value) => value .as_u64() .filter(|value| *value <= 10000) .ok_or("reserve_cash_slots must be an integer between 0 and 10000")? as usize, None => 0, }; if reserve_cash_slots > 0 && target_holding_count.is_none() { return Err("cash reserve slots require target_holding_count".into()); } let stop = |keys: &[&str]| -> Result, String> { let mut found = None; for key in keys { if let Some(value) = stops.get(*key) { let next = if value.is_null() || value.as_str() == Some("") { None } else { let text = value .as_str() .map(str::to_owned) .unwrap_or_else(|| value.to_string()); let number = text .parse::() .map_err(|_| "invalid stop/take ratio")?; if number < Decimal::ZERO || number >= Decimal::ONE { return Err("stop/take ratio must be in [0,1)".into()); } if number == Decimal::ZERO { None } else { Some(number) } }; if found.is_some_and(|old| old != next) { return Err("stop/take aliases conflict".into()); } found = Some(next); } } Ok(found.flatten()) }; Ok(StockPoolDecisionConstraints { market_timing_policy: Some( crate::stock_pool_index_policy::MarketTimingPolicy::from_allocation(allocation)?, ), portfolio_policy: Some(policy), target_holding_count, reserve_cash_slots, default_stop_loss: stop(&["stop_loss", "stopLoss"])?, default_take_profit: stop(&["take_profit", "takeProfit"])?, ..Default::default() }) } #[derive(Debug, Clone)] pub struct AccountSnapshot { pub total_equity: Decimal, pub cash: Decimal, pub frozen_cash: Decimal, } #[derive(Debug, Clone)] pub struct Position { pub symbol: String, pub quantity: Decimal, pub closable_quantity: Decimal, pub average_cost: Decimal, } #[derive(Debug, Clone)] pub struct MarketSnapshot { pub symbol: String, pub last_price: Decimal, pub prev_close: Option, pub volume: Option, pub turnover: Option, pub bid_price_1: Option, pub ask_price_1: Option, pub is_kcb: Option, pub instrument_rules: Option, /// Explicit execution-adapter estimates; omission means the declared last /// price model, never replacement of an invalid supplied value. pub buy_sizing_price: Option, pub sell_sizing_price: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolInstrumentRules { pub price_tick: Decimal, pub quantity_step: Decimal, pub minimum_buy_quantity: Decimal, } impl StockPoolInstrumentRules { pub fn validate(&self) -> Result<(), String> { if self.price_tick <= Decimal::ZERO || self.price_tick > Decimal::ONE || self.quantity_step <= Decimal::ZERO || self.quantity_step.fract() != Decimal::ZERO || self.minimum_buy_quantity < self.quantity_step || self.minimum_buy_quantity.fract() != Decimal::ZERO { return Err("stock_pool_instrument_rules_invalid".into()); } Ok(()) } } pub fn stock_pool_instrument_rules( snapshot: &MarketSnapshot, ) -> Result { if let Some(rules) = &snapshot.instrument_rules { rules.validate()?; return Ok(rules.clone()); } let is_kcb = snapshot.is_kcb.ok_or_else(|| { format!( "listed_sector_classification_missing: symbol={}", snapshot.symbol ) })?; let (quantity_step, minimum_buy_quantity) = if is_kcb { (Decimal::ONE, Decimal::from(200)) } else if snapshot.symbol.ends_with(".BJ") { (Decimal::ONE, Decimal::from(100)) } else { (Decimal::from(100), Decimal::from(100)) }; Ok(StockPoolInstrumentRules { price_tick: Decimal::new(1, 2), quantity_step, minimum_buy_quantity, }) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StockPoolMemberSpec { pub symbol: String, #[serde(default)] pub recommendation_reason: String, #[serde(default)] pub requested_order: i32, #[serde(default)] pub target_weight_bps: Option, #[serde(default)] pub stop_loss: Option, #[serde(default)] pub take_profit: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct StockPoolExecutionRule { #[serde(default, alias = "buyConditionScope")] pub buy_condition_scope: Option, #[serde(default, alias = "sellConditionScope")] 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" )] pub automatic_trade_protection: crate::holding_policy::AutomaticTradeProtection, #[serde(alias = "schemaVersion")] pub schema_version: u32, #[serde( alias = "autoExecute", alias = "daily_auto_execute", alias = "autoTrade" )] pub auto_execute: bool, #[serde(alias = "freezeCutoff")] pub freeze_time: String, #[serde(alias = "triggerMode")] pub trigger_mode: String, #[serde(alias = "windowStart")] pub window_start: String, #[serde(alias = "windowEnd")] pub window_end: String, #[serde(alias = "condition")] pub buy_condition: String, #[serde(alias = "sellTriggerMode")] pub sell_trigger_mode: String, #[serde(alias = "sellCondition")] pub sell_condition: String, #[serde(alias = "pricingMode")] pub pricing_mode: String, #[serde(alias = "fixedPrice")] pub fixed_price: Option, #[serde(alias = "fixedPrices")] pub fixed_prices: BTreeMap, #[serde(alias = "buyOffsetBps")] pub buy_offset_bps: i32, #[serde(alias = "sellOffsetBps")] pub sell_offset_bps: i32, #[serde(alias = "timeInForce")] pub time_in_force: String, #[serde(alias = "missedWindowPolicy")] pub missed_window_policy: String, #[serde(alias = "maxChildOrders")] pub max_child_orders: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StockPoolSelection { pub trade_date: NaiveDate, pub requested_symbols: Vec, pub normal_trading_symbols: Vec, pub risk_eligible_symbols: Vec, pub final_symbols: Vec, #[serde(default)] pub exclusion_reasons: BTreeMap>, #[serde(default)] pub inherited_from_generation: Option, #[serde(default)] pub explicit_empty: bool, #[serde(default)] pub generation: Option, } #[derive(Debug, Clone, Default, PartialEq)] pub struct StockPoolDecisionConstraints { pub execution_date: Option, pub frozen_positions: BTreeMap, pub prior_target_weights: BTreeMap, pub pending_entry_symbols: BTreeSet, pub next_day_outside_exit_symbols: BTreeSet, pub market_timing_policy: Option, pub market_timing_input: Option, pub portfolio_policy: Option, pub target_holding_count: Option, pub reserve_cash_slots: usize, 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, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FrozenStockPoolPosition { pub trade_date: NaiveDate, pub reason: String, pub valuation_price: Decimal, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StockPoolPlanRow { pub symbol: String, pub target_weight_bps: i32, pub target_value: Decimal, pub current_quantity: Decimal, pub target_quantity: Decimal, pub delta_quantity: Decimal, pub side: Option, pub status: String, pub reason: String, pub reference_price: Option, pub order_type: Option, pub limit_price: Option, pub source_intent: Option, } #[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, pub estimated_buy_amount: Decimal, pub estimated_sell_amount: Decimal, pub estimated_cash_after: Decimal, pub requested_invest_ratio_bps: i32, pub effective_invest_ratio_bps: Decimal, pub retained_slots: usize, } /// A signal-time contract. Only the broker/execution adapter supplies later /// prices, actual cash and holdings; strategy code never sees those inputs. #[derive(Debug, Clone)] pub struct FrozenStockPoolIntent { pub pool_id: String, pub signal_date: NaiveDate, pub frozen_equity: Decimal, pub selection: StockPoolSelection, pub members: Vec, pub rule: StockPoolExecutionRule, pub constraints: StockPoolDecisionConstraints, pub invest_ratio_bps: i32, pub reserve_cash: Decimal, pub out_of_pool_policy: String, pub generation: String, } /// Complete immutable pool execution configuration, distinct from a generated /// code strategy. Source screening/event evidence remains separately bound. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StockPoolProgram { pub schema_version: u32, pub pool_id: String, pub version_id: String, pub members: Vec, pub allocation_policy: Value, 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 { pub fn validate(&self, secondary_buy: bool, secondary_sell: bool) -> Result<(), String> { if self.schema_version != 1 || self.pool_id.trim().is_empty() || self.version_id.trim().is_empty() { return Err("stock_pool_program_identity_invalid".into()); } 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)?; 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 || 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(), "hold" | "reduce_to_zero_when_sellable" | "reduce_next_trading_day" ) { return Err("stock_pool_program_outside_policy_invalid".into()); } Ok(()) } } impl Default for StockPoolExecutionRule { fn default() -> Self { Self { 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, freeze_time: "09:20".to_string(), trigger_mode: POOL_TRIGGER_FIRST_TICK.to_string(), window_start: "09:30".to_string(), window_end: "09:35".to_string(), buy_condition: String::new(), sell_trigger_mode: POOL_SELL_TARGET_DELTA.to_string(), sell_condition: String::new(), pricing_mode: POOL_PRICE_FORMULA_LIMIT.to_string(), fixed_price: None, fixed_prices: BTreeMap::new(), buy_offset_bps: 0, sell_offset_bps: 0, time_in_force: "DAY".to_string(), missed_window_policy: "intraday_catchup".to_string(), max_child_orders: 1, } } } pub fn build_stock_pool_target_plan_with_constraints( selection: &StockPoolSelection, members: &[StockPoolMemberSpec], rule: &StockPoolExecutionRule, account: &AccountSnapshot, positions: &[Position], quotes: &[MarketSnapshot], invest_ratio_bps: i32, reserve_cash: Decimal, out_of_pool_policy: &str, top_n_rebalance_policy: &str, constraints: &StockPoolDecisionConstraints, generation: &str, commission_rate: Decimal, minimum_commission: Decimal, stamp_tax_rate: Decimal, ) -> Result { build_stock_pool_target_plan_with_fee_model( selection, members, rule, account, positions, quotes, invest_ratio_bps, reserve_cash, out_of_pool_policy, top_n_rebalance_policy, constraints, generation, commission_rate, minimum_commission, stamp_tax_rate, None, ) } pub fn build_stock_pool_target_plan_with_fee_model( selection: &StockPoolSelection, members: &[StockPoolMemberSpec], rule: &StockPoolExecutionRule, account: &AccountSnapshot, positions: &[Position], quotes: &[MarketSnapshot], invest_ratio_bps: i32, reserve_cash: Decimal, out_of_pool_policy: &str, top_n_rebalance_policy: &str, constraints: &StockPoolDecisionConstraints, generation: &str, commission_rate: Decimal, minimum_commission: Decimal, stamp_tax_rate: Decimal, fee_model: Option<&dyn Fn(&str, OrderSide, Decimal) -> Result>, ) -> Result { if rule.automatic_trade_protection.enabled() { for symbol in stock_pool_execution_quote_symbols(&selection.requested_symbols, positions) { if !constraints.automatic_permissions.contains_key(&symbol) { return Err(format!( "automatic_trade_protection_permission_missing:{symbol}" )); } } } // 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.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); } } let same_day_sold_symbols = &constraints.same_day_sold_symbols; if !(0..=10_000).contains(&invest_ratio_bps) { return Err("invest_ratio_bps must be between 0 and 10000".to_string()); } if reserve_cash < Decimal::ZERO { return Err("reserve_cash must be non-negative".to_string()); } if commission_rate < Decimal::ZERO || minimum_commission < Decimal::ZERO || stamp_tax_rate < Decimal::ZERO { return Err("stock pool fee settings must be non-negative".to_string()); } let fee_for = |symbol: &str, side: OrderSide, gross: Decimal| -> Result { let fee = if let Some(model) = fee_model { model(symbol, side, gross)? } else { commission_for_notional(gross, commission_rate, minimum_commission) + if side == OrderSide::Sell { gross * stamp_tax_rate } else { Decimal::ZERO } }; if fee < Decimal::ZERO { return Err("stock pool fee model returned a negative cost".into()); } Ok(fee) }; if !matches!( out_of_pool_policy, "hold" | "reduce_to_zero_when_sellable" | "reduce_next_trading_day" ) { return Err(format!( "unsupported out_of_pool_policy={out_of_pool_policy}" )); } let portfolio_policy = constraints .portfolio_policy .clone() .map(Ok) .unwrap_or_else(|| StockPoolPortfolioPolicy::from_legacy(top_n_rebalance_policy))?; portfolio_policy.validate()?; if constraints.target_holding_count == Some(0) || (constraints.reserve_cash_slots > 0 && constraints.target_holding_count.is_none()) { return Err("cash reserve slots require a positive target holding count".into()); } let mut normalized_members = normalize_stock_pool_members(members)?; for member in &mut normalized_members { if member.stop_loss.is_none() { member.stop_loss = constraints.default_stop_loss; } if member.take_profit.is_none() { member.take_profit = constraints.default_take_profit; } } let quote_map = quotes .iter() .filter_map(|quote| normalize_stock_symbol("e.symbol).map(|symbol| (symbol, quote))) .collect::>(); let mut current = BTreeMap::::new(); for position in positions { let Some(symbol) = normalize_stock_symbol(&position.symbol) else { continue; }; if position.quantity < Decimal::ZERO || position.closable_quantity < Decimal::ZERO || position.closable_quantity > position.quantity { return Err(format!("invalid managed stock position quantity:{symbol}")); } if current .insert( symbol.clone(), ( position.quantity, position.closable_quantity, position.average_cost, ), ) .is_some() { return Err(format!("duplicate managed stock position:{symbol}")); } } 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); } if portfolio_policy.membership == MembershipPolicy::RetainHoldings && out_of_pool_policy == "hold" { let known = normalized_members .iter() .map(|member| member.symbol.clone()) .collect::>(); for (symbol, (quantity, _, _)) in ¤t { if *quantity > Decimal::ZERO && !known.contains(symbol) { normalized_members.push(StockPoolMemberSpec { symbol: symbol.clone(), requested_order: normalized_members.len() as i32, recommendation_reason: String::new(), target_weight_bps: None, stop_loss: constraints.default_stop_loss, take_profit: constraints.default_take_profit, }); } } } let member_map = normalized_members .iter() .map(|member| (member.symbol.clone(), member)) .collect::>(); let explicit_weights = normalized_members .iter() .filter_map(|member| { member .target_weight_bps .map(|weight| (member.symbol.clone(), weight)) }) .collect::>(); let mut original_final_symbols = normalize_symbol_list(&selection.final_symbols)?; let capacity = constraints.target_holding_count.unwrap_or_else(|| { original_final_symbols.len() + constraints .frozen_positions .keys() .filter(|symbol| { member_map.contains_key(*symbol) && !original_final_symbols.contains(symbol) }) .count() }); if portfolio_policy.membership == MembershipPolicy::RetainHoldings { let retained = normalized_members .iter() .filter(|member| { current .get(&member.symbol) .is_some_and(|row| row.0 > Decimal::ZERO) && !effective_position_targets.contains_key(&member.symbol) }) .map(|member| member.symbol.clone()) .collect::>(); let mut chosen = retained.into_iter().take(capacity).collect::>(); for symbol in &original_final_symbols { if chosen.len() >= capacity { break; } if !chosen.contains(symbol) { chosen.push(symbol.clone()) } } original_final_symbols = chosen; } else { original_final_symbols.truncate(capacity); } let mut protected_positions = constraints .automatic_permissions .iter() .filter(|(symbol, permission)| { permission.sell_denial.is_some() && current .get(*symbol) .is_some_and(|position| position.0 > Decimal::ZERO) }) .map(|(symbol, _)| symbol.clone()) .collect::>(); protected_positions.extend(constraints.frozen_positions.keys().cloned()); let global_stop_hits = current .iter() .filter_map(|(symbol, (quantity, _, cost))| { let member = member_map.get(symbol)?; let quote = quote_map.get(symbol)?; (*quantity > Decimal::ZERO && *cost > Decimal::ZERO && !protected_positions.contains(symbol) && (member.stop_loss.is_some_and(|stop| { stop > Decimal::ZERO && quote.last_price <= *cost * (Decimal::ONE - stop) }) || member.take_profit.is_some_and(|take| { take > Decimal::ZERO && quote.last_price >= *cost * (Decimal::ONE + take) }))) .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 { 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. let held = current .iter() .filter(|(symbol, row)| { 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 = 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 = ordinary_enabled && qualified.get(&symbol) == Some(&true) && (!rule.secondary_sell_condition || constraints.position_target_bps.contains_key(&symbol)); if !permitted { 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); } } protected_positions.extend(sell_condition_denials.iter().cloned()); } let factor_position_target_bps = &effective_position_targets; let reserved_protected_slots = protected_positions .iter() .filter(|symbol| !original_final_symbols.contains(symbol)) .count(); let maximum_holding_exits = constraints .automatic_permissions .iter() .filter(|(symbol, permission)| { permission.max_holding_exit && !constraints.frozen_positions.contains_key(*symbol) }) .map(|(symbol, _)| symbol.clone()) .collect::>(); let mut stop_take_exits = global_stop_hits; stop_take_exits.extend(quote_sell_exits.iter().cloned()); for symbol in &original_final_symbols { if protected_positions.contains(symbol) { continue; } let Some(member) = member_map.get(symbol) else { continue; }; let Some((quantity, _closable, cost)) = current.get(symbol) else { continue; }; let Some(quote) = quote_map.get(symbol) else { continue; }; if *quantity > Decimal::ZERO && *cost > Decimal::ZERO && (member.stop_loss.is_some_and(|stop| { stop > Decimal::ZERO && quote.last_price <= *cost * (Decimal::ONE - stop) }) || member.take_profit.is_some_and(|take| { take > Decimal::ZERO && quote.last_price >= *cost * (Decimal::ONE + take) })) { stop_take_exits.insert(symbol.clone()); } } stop_take_exits.extend(maximum_holding_exits.iter().cloned()); 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() .filter(|symbol| { current .get(*symbol) .is_none_or(|row| row.0 == Decimal::ZERO) }) .cloned(), ); let target_count = capacity.saturating_sub(reserved_protected_slots); let free_slots = capacity.saturating_sub(protected_positions.len()); let mut unprotected_count = 0; let mut active_symbols = original_final_symbols .iter() .filter(|symbol| !rebuy_exclusions.contains(*symbol)) .filter(|symbol| { if protected_positions.contains(*symbol) { return true; } if unprotected_count >= free_slots { return false; } unprotected_count += 1; true }) .cloned() .collect::>(); let mut promoted_symbols = Vec::new(); let risk_eligible_symbols = normalize_symbol_set(&selection.risk_eligible_symbols)?; let normal_symbols = normalize_symbol_set(&selection.normal_trading_symbols)?; for raw_symbol in &selection.requested_symbols { if active_symbols.len() >= target_count { break; } let Some(symbol) = normalize_stock_symbol(raw_symbol) else { continue; }; if rebuy_exclusions.contains(&symbol) || !risk_eligible_symbols.contains(&symbol) || !normal_symbols.contains(&symbol) || selection.exclusion_reasons.contains_key(&symbol) || factor_position_target_bps.contains_key(&symbol) || active_symbols.contains(&symbol) || !member_map.contains_key(&symbol) || !quote_map.contains_key(&symbol) { continue; } active_symbols.push(symbol.clone()); promoted_symbols.push(symbol); } let mut weights = if explicit_weights.is_empty() && constraints.frozen_positions.is_empty() { let count = (active_symbols.len() + reserved_protected_slots) as i32; let (share, remainder) = if count == 0 { (0, 0) } else { (10_000 / count, 10_000 % count) }; active_symbols .iter() .enumerate() .map(|(index, symbol)| { ( symbol.clone(), share + i32::from((index as i32) < remainder), ) }) .collect::>() } else { frozen::weights( &original_final_symbols, &active_symbols, &normalized_members, &explicit_weights, constraints, reserved_protected_slots, target_count, )? }; for symbol in &rebuy_exclusions { if original_final_symbols.contains(symbol) || current.contains_key(symbol) { weights.insert(symbol.clone(), 0); } } let mut planning_symbols = active_symbols; for symbol in &original_final_symbols { // An explicit quote/expiry position action owns its single target row. // Keep it excluded from entry sizing without adding a second stop row. if rebuy_exclusions.contains(symbol) && !factor_position_target_bps.contains_key(symbol) && !planning_symbols.contains(symbol) { planning_symbols.push(symbol.clone()); } } for symbol in &maximum_holding_exits { if member_map.contains_key(symbol) && !factor_position_target_bps.contains_key(symbol) && !planning_symbols.contains(symbol) { weights.insert(symbol.clone(), 0); planning_symbols.push(symbol.clone()); } } for symbol in &stop_take_exits { if current .get(symbol) .is_some_and(|position| position.0 > Decimal::ZERO) && member_map.contains_key(symbol) && !factor_position_target_bps.contains_key(symbol) && !planning_symbols.contains(symbol) { weights.insert(symbol.clone(), 0); planning_symbols.push(symbol.clone()); } } let active_target_symbols = weights .iter() .filter_map(|(symbol, weight)| (*weight > 0).then_some(symbol.clone())) .collect::>(); let buy_condition_allowed = if !rule.buy_condition.trim().is_empty() { quote_condition_results( &rule.buy_condition, rule.buy_condition_scope, &active_target_symbols .iter() .filter(|symbol| !constraints.frozen_positions.contains_key(*symbol)) .cloned() .collect::>(), "e_map, )? } else { BTreeMap::new() }; let top_n_demoted_symbols = if selection.final_symbols.len() < selection.risk_eligible_symbols.len() { current .iter() .filter_map(|(symbol, (quantity, _, _))| { (*quantity > Decimal::ZERO && risk_eligible_symbols.contains(symbol) && !active_target_symbols.contains(symbol) && !factor_position_target_bps.contains_key(symbol)) .then_some(symbol.clone()) }) .collect::>() } else { BTreeSet::new() }; for symbol in &top_n_demoted_symbols { weights.insert(symbol.clone(), 0); if !planning_symbols.contains(symbol) { planning_symbols.push(symbol.clone()); } } let occupied_slots = planning_symbols .iter() .filter(|symbol| weights.get(*symbol).is_some_and(|weight| *weight > 0)) .count() + reserved_protected_slots; let seat_scale = if constraints.reserve_cash_slots > 0 { Decimal::from(occupied_slots.min(capacity) as u64) / Decimal::from( capacity .checked_add(constraints.reserve_cash_slots) .ok_or("stock pool seat count overflow")? as u64, ) } else { Decimal::ONE }; let market_timing = constraints .market_timing_policy .as_ref() .filter(|policy| policy.enabled) .map(|policy| { let input = constraints .market_timing_input .as_ref() .ok_or("market_timing_verified_completed_index_input_required")?; crate::stock_pool_index_policy::evaluate(policy, input, selection.trade_date) }) .transpose()?; let market_exposure = market_timing .as_ref() .map(|result| { result .exposure .to_string() .parse::() .map_err(|_| "market_timing_exposure_out_of_range") }) .transpose()? .unwrap_or(Decimal::ONE); let effective_invest_ratio_bps = Decimal::from(invest_ratio_bps) * market_exposure * seat_scale; let base_budget = account.total_equity * Decimal::from(invest_ratio_bps) / Decimal::from(10_000) * market_exposure; let investment_budget = if constraints.reserve_cash_slots > 0 { base_budget * Decimal::from(occupied_slots.min(capacity) as u64) / Decimal::from((capacity + constraints.reserve_cash_slots) as u64) } else { base_budget }; let budget = (investment_budget - reserve_cash).max(Decimal::ZERO); let requested_weight_total = if explicit_weights.is_empty() { 10_000 } else { original_final_symbols .iter() .map(|symbol| *explicit_weights.get(symbol).unwrap_or(&0)) .sum::() .max(weights.values().copied().sum()) }; let mut protected_values = protected_positions .iter() .map(|symbol| { let current_value = current[symbol].0 * frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?; let desired = budget * Decimal::from(*weights.get(symbol).unwrap_or(&0)) / Decimal::from(10_000); if constraints.frozen_positions.contains_key(symbol) { return Ok((symbol.clone(), desired)); } let fixed = constraints .automatic_permissions .get(symbol) .is_some_and(|permission| permission.buy_denial.is_some()); Ok(( symbol.clone(), if fixed { current_value } else { current_value.max(desired) }, )) }) .collect::, String>>()?; // Retaining completed holdings consumes their actual value. New slots // share only the remaining budget; their smaller allocation is not a // fictitious cash failure caused by an equal-weight target for old names. if !portfolio_policy.rebalance_weights && budget > Decimal::ZERO { for symbol in &planning_symbols { if weights.get(symbol).is_some_and(|weight| *weight > 0) && !constraints.frozen_positions.contains_key(symbol) && !constraints.pending_entry_symbols.contains(symbol) && !stop_take_exits.contains(symbol) && let Some((quantity, _, _)) = current.get(symbol).filter(|row| row.0 > Decimal::ZERO) { let price = quote_map .get(symbol) .ok_or_else(|| format!("{symbol} retained holding quote missing"))? .last_price; protected_values.insert(symbol.clone(), *quantity * price); } } } let free_desired = weights .iter() .filter(|(symbol, _)| !protected_values.contains_key(*symbol)) .map(|(_, weight)| budget * Decimal::from(*weight) / Decimal::from(10_000)) .sum::(); let free_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10_000) - protected_values.values().copied().sum::()) .max(Decimal::ZERO); let free_scale = if protected_values.is_empty() || free_desired <= Decimal::ZERO { Decimal::ONE } else { (free_budget / free_desired).min(Decimal::ONE) }; let mut rows = Vec::new(); let mut estimated_buy_amount = Decimal::ZERO; let mut estimated_sell_amount = Decimal::ZERO; for (symbol, fact) in &constraints.frozen_positions { let quantity = current[symbol].0; let weight = *weights.get(symbol).unwrap_or(&0); rows.push(StockPoolPlanRow { symbol: symbol.clone(), target_weight_bps: weight, target_value: budget * Decimal::from(weight) / Decimal::from(10_000), current_quantity: quantity, target_quantity: quantity, delta_quantity: Decimal::ZERO, side: None, status: "MARKET_SUSPENDED".into(), reason: "当日正式停牌,保留原预算与席位;估值不作为成交报价".into(), reference_price: Some(fact.valuation_price), order_type: None, limit_price: None, source_intent: None, }); } // Out-of-pool positions are intentionally explicit. The default is hold; // this prevents an edited pool from silently liquidating unrelated work. for (symbol, (quantity, closable, _cost)) in current.iter().filter(|(symbol, _)| { !member_map.contains_key(*symbol) && !factor_position_target_bps.contains_key(*symbol) && !constraints.frozen_positions.contains_key(*symbol) }) { let outside_policy = if maximum_holding_exits.contains(symbol) || (out_of_pool_policy == "reduce_next_trading_day" && constraints.next_day_outside_exit_symbols.contains(symbol)) { "reduce_to_zero_when_sellable" } else { out_of_pool_policy }; let (status, delta, target, side, reason) = match outside_policy { "hold" => ( "OUT_OF_SCOPE", Decimal::ZERO, *quantity, None, "持仓不在本股票池管理范围,按配置继续持有", ), "reduce_next_trading_day" => ( "DEFERRED_T_PLUS_ONE", Decimal::ZERO, *quantity, None, "移出股票池,按配置顺延到下一交易日处理", ), _ => { let sellable = (*closable).min(*quantity); if sellable > Decimal::ZERO { estimated_sell_amount += sellable * quote_map .get(symbol) .map(|quote| quote.last_price) .unwrap_or(Decimal::ZERO); ( "READY", -sellable, *quantity - sellable, Some(OrderSide::Sell), "移出股票池,按配置清仓可卖数量", ) } else { ( "DEFERRED_T_PLUS_ONE", Decimal::ZERO, *quantity, None, "移出股票池,当前没有可卖数量", ) } } }; let (outside_order_type, outside_limit_price) = if delta != Decimal::ZERO { let quote = quote_map .get(symbol) .ok_or_else(|| format!("{symbol} missing current execution quote"))?; let (kind, price) = resolve_stock_pool_order_price( rule, symbol, quote.last_price, OrderSide::Sell, stock_pool_instrument_rules(quote)?.price_tick, )?; (Some(kind), price) } else { (None, None) }; rows.push(StockPoolPlanRow { symbol: symbol.clone(), target_weight_bps: 0, target_value: Decimal::ZERO, current_quantity: *quantity, target_quantity: target, delta_quantity: delta, side, status: status.to_string(), reason: reason.to_string(), reference_price: quote_map.get(symbol).map(|quote| quote.last_price), order_type: outside_order_type, limit_price: outside_limit_price, source_intent: (delta != Decimal::ZERO) .then(|| format!("stock_pool:{generation}:{symbol}:sell")), }); } for (symbol, target_bps) in factor_position_target_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" )); } let current_quantity = current .get(symbol) .map(|value| value.0) .unwrap_or(Decimal::ZERO); let closable_quantity = current .get(symbol) .map(|value| value.1) .unwrap_or(current_quantity); let quote = quote_map.get(symbol); let step = if *target_bps == 0 || current_quantity == Decimal::ZERO { Decimal::ONE } else { order_quantity_rules(quote.ok_or_else(|| { format!("{symbol} missing current execution quote for factor reduction") })?)? .0 }; let requested_target = if *target_bps == 0 { Decimal::ZERO } else { floor_step( 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) } else { floor_step( desired_reduction.min(closable_quantity).max(Decimal::ZERO), step, ) }; let (status, reason, delta, target, side, order_type, limit_price) = 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", "生产因子持仓动作命中,当前没有可卖数量", Decimal::ZERO, current_quantity, None, None, None, ) } else { let quote = quote.ok_or_else(|| { format!("{symbol} missing current execution quote for factor exit") })?; let (kind, price) = resolve_stock_pool_order_price( rule, symbol, quote.last_price, OrderSide::Sell, stock_pool_instrument_rules(quote)?.price_tick, )?; estimated_sell_amount += executable * quote.last_price; ( "READY", if maximum_holding_exits.contains(symbol) { "达到最长持有期,按配置退出" } else if quote_sell_exits.contains(symbol) { "卖出行情条件命中" } 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 { "生产因子减仓条件命中" }, -executable, current_quantity - executable, Some(OrderSide::Sell), Some(kind), price, ) }; rows.push(StockPoolPlanRow { symbol: symbol.clone(), target_weight_bps: *target_bps as i32, target_value: target * quote.map(|value| value.last_price).unwrap_or(Decimal::ZERO), current_quantity, target_quantity: target, delta_quantity: delta, side, status: status.to_string(), reason: reason.to_string(), reference_price: quote.map(|value| value.last_price), order_type, limit_price, source_intent: (delta != Decimal::ZERO).then(|| { format!( "stock_pool:{generation}:{symbol}:{}", if *target_bps == 0 { "factor_sell" } else { "factor_reduce" } ) }), }); } for symbol in &planning_symbols { if constraints.frozen_positions.contains_key(symbol) { continue; } if !member_map.contains_key(symbol) { return Err(format!("selection symbol {symbol} is missing from members")); } let current_quantity = current .get(symbol) .map(|value| value.0) .unwrap_or(Decimal::ZERO); let closable_quantity = current .get(symbol) .map(|value| value.1) .unwrap_or(current_quantity); let quote = quote_map .get(symbol) .ok_or_else(|| format!("{symbol} missing current execution quote"))?; if quote.last_price <= Decimal::ZERO { return Err(format!("{symbol} execution quote is invalid")); } let mut weight = *weights.get(symbol).unwrap_or(&0); let forced_exit = stop_take_exits.contains(symbol); if forced_exit { weight = 0; } let target_value = protected_values .get(symbol) .copied() .unwrap_or(budget * Decimal::from(weight) / Decimal::from(10_000) * free_scale) .round_dp_with_strategy(2, RoundingStrategy::MidpointNearestEven); let sizing_price = if target_value >= current_quantity * quote.last_price { quote.buy_sizing_price.unwrap_or(quote.last_price) } else { quote.sell_sizing_price.unwrap_or(quote.last_price) }; if sizing_price <= Decimal::ZERO { return Err(format!("{symbol} execution sizing price is invalid")); } let raw_target = (target_value / sizing_price).floor(); let (step, minimum_buy) = order_quantity_rules(quote)?; let mut target_quantity = current_quantity; let mut delta = Decimal::ZERO; let mut status = "ALREADY_SATISFIED".to_string(); let mut reason = "目标仓位已满足".to_string(); let preserve_existing = !portfolio_policy.rebalance_weights && weight > 0 && target_value > Decimal::ZERO && current_quantity > Decimal::ZERO && !forced_exit && (!constraints.pending_entry_symbols.contains(symbol) || raw_target <= current_quantity); if preserve_existing { if constraints.pending_entry_symbols.contains(symbol) { status = "ENTRY_TARGET_ALREADY_SATISFIED".into(); reason = "本轮建仓目标已满足,后续按配置保留股数".into(); } else { status = "PRESERVED_EXISTING_POSITION".to_string(); reason = "按配置保留当前持仓,仅调整 Top N 进出名单".to_string(); } } else if raw_target > current_quantity { let desired = raw_target - current_quantity; let executable = floor_step(desired, step); if buy_condition_allowed.get(symbol) == Some(&false) { status = "BUY_CONDITION_PENDING".into(); reason = "买入行情条件尚未满足".into(); } else if let Some(reasons) = constraints.buy_denials.get(symbol) { status = "BUY_FACTOR_BLOCKED".to_string(); reason = format!("买入因子条件未满足,保留当前持仓: {}", reasons.join("; ")); } else if normalized_same_day_sold.contains(symbol) { status = "BUY_FACTOR_BLOCKED".into(); reason = "当日已卖出,禁止增加仓位;不因此清仓剩余持仓".into(); } else if executable < minimum_buy { status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".to_string(); reason = if current_quantity == Decimal::ZERO { "目标股数不足该证券首笔最小交易单位,无需生成委托" } else { "目标差额不足一个最小交易单位,无需生成委托" } .to_string(); } else { target_quantity += executable; delta = executable; status = "READY".to_string(); reason.clear(); } } else if raw_target < current_quantity { let desired = current_quantity - raw_target; let executable = if raw_target == Decimal::ZERO { closable_quantity } else { floor_step(desired, step).min(floor_step(closable_quantity, step)) }; if executable > Decimal::ZERO { target_quantity -= executable; delta = -executable; status = "READY".to_string(); reason = if maximum_holding_exits.contains(symbol) { "达到最长持有期,按配置退出".to_string() } else if forced_exit { "止损/止盈触发".to_string() } else if top_n_demoted_symbols.contains(symbol) { "Top N 优先级调整,移出当前持仓槽位".to_string() } else { String::new() }; estimated_sell_amount += executable * quote.last_price; } else if closable_quantity < desired { status = "DEFERRED_T_PLUS_ONE".to_string(); reason = "可卖数量不足,剩余减仓顺延到下一交易日".to_string(); } else { status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".to_string(); reason = "目标差额不足一个最小交易单位".to_string(); } } let (order_type, limit_price) = if delta == Decimal::ZERO { (None, None) } else { let side = if delta > Decimal::ZERO { OrderSide::Buy } else { OrderSide::Sell }; let (kind, price) = resolve_stock_pool_order_price( rule, symbol, quote.last_price, side, stock_pool_instrument_rules(quote)?.price_tick, )?; (Some(kind), price) }; if delta > Decimal::ZERO { estimated_buy_amount += delta * limit_price.unwrap_or(quote.last_price); } rows.push(StockPoolPlanRow { symbol: symbol.clone(), target_weight_bps: weight, target_value, current_quantity, target_quantity, delta_quantity: delta, side: (delta != Decimal::ZERO).then(|| { if delta > Decimal::ZERO { OrderSide::Buy } else { OrderSide::Sell } }), status, reason, reference_price: Some(sizing_price), order_type, limit_price, source_intent: (delta != Decimal::ZERO).then(|| { format!( "stock_pool:{generation}:{symbol}:{}", if delta > Decimal::ZERO { "buy" } else { "sell" } ) }), }); } for symbol in &protected_positions { if current .get(symbol) .is_some_and(|position| position.0 > Decimal::ZERO) && !rows.iter().any(|row| &row.symbol == symbol) { let quantity = current[symbol].0; let price = quote_map .get(symbol) .ok_or_else(|| format!("retained position quote missing:{symbol}"))? .last_price; rows.push(StockPoolPlanRow { symbol: symbol.clone(), target_weight_bps: 0, target_value: quantity * price, current_quantity: quantity, target_quantity: quantity, delta_quantity: Decimal::ZERO, side: None, status: if sell_condition_denials.contains(symbol) { "SELL_CONDITION_PENDING" } else { "AUTOMATIC_TRADE_PROTECTED" } .into(), reason: "持仓保留,继续占用资金与席位".into(), reference_price: Some(price), order_type: None, limit_price: None, source_intent: None, }); } } for row in &mut rows { if sell_condition_denials.contains(&row.symbol) && row.delta_quantity <= Decimal::ZERO { row.status = "SELL_CONDITION_PENDING".into(); row.reason = "卖出条件尚未全部满足,保留持仓与席位".into(); row.target_quantity = row.current_quantity; row.delta_quantity = Decimal::ZERO; row.side = None; row.order_type = None; row.limit_price = None; row.source_intent = None; } if let Some(permission) = constraints.automatic_permissions.get(&row.symbol) { let denial = if row.delta_quantity < Decimal::ZERO { permission.sell_denial } else if row.delta_quantity > Decimal::ZERO { permission.buy_denial } else { permission.sell_denial }; if let Some(reason) = denial { row.status = "AUTOMATIC_TRADE_PROTECTED".into(); row.reason = match reason { "automatic_trade_locked" => "锁定区间内保留持仓,不自动买卖", "buy_fill_protection" => "买入成交后保护期内,不自动卖出", "sell_fill_cooldown" => "卖出成交后禁买期内,不自动增加仓位", "maximum_holding_exit" => "达到最长持有期,不再自动加仓", _ => reason, } .into(); row.delta_quantity = Decimal::ZERO; row.target_quantity = row.current_quantity; row.target_value = row.current_quantity * row.reference_price.unwrap_or(Decimal::ZERO); row.side = None; row.order_type = None; row.limit_price = None; row.source_intent = None; } } } // Verify disjoint planning ownership before an index cap can address rows // by symbol. Never deduplicate emitted intentions or count proceeds twice. let mut owners = BTreeSet::new(); for row in &rows { if !owners.insert(row.symbol.as_str()) { return Err(format!("stock_pool_target_owner_conflict:{}", row.symbol)); } } if market_timing.is_some() { let caps = index_cap::remaining_index_targets( ¤t, &member_map, &constraints.automatic_permissions, &rows, "e_map, &constraints.frozen_positions, budget * Decimal::from(requested_weight_total) / Decimal::from(10000), )?; let mut indices = rows .iter() .enumerate() .map(|(index, row)| (row.symbol.clone(), index)) .collect::>(); for (symbol, cap) in caps { let (quantity, closable, _) = current[&symbol]; let quote = quote_map[&symbol]; let (step, _) = order_quantity_rules(quote)?; let desired = (quantity - cap.quantity).max(Decimal::ZERO); let executable = if cap.quantity == Decimal::ZERO { closable.min(quantity) } else { floor_step(desired.min(closable), step) }; let index = if let Some(index) = indices.get(&symbol) { *index } else { let index = rows.len(); rows.push(StockPoolPlanRow { symbol: symbol.clone(), target_weight_bps: *weights.get(&symbol).unwrap_or(&0), target_value: quantity * quote.last_price, current_quantity: quantity, target_quantity: quantity, delta_quantity: Decimal::ZERO, side: None, status: "ALREADY_SATISFIED".into(), reason: String::new(), reference_price: Some(quote.last_price), order_type: None, limit_price: None, source_intent: None, }); indices.insert(symbol.clone(), index); index }; let row = &mut rows[index]; if executable > Decimal::ZERO { row.target_quantity = quantity - executable; row.target_value = cap.quantity * quote.last_price; row.delta_quantity = -executable; row.side = Some(OrderSide::Sell); row.status = "READY".into(); row.reason = "指数总仓位收缩,按剩余持仓比例减仓".into(); let (kind, price) = resolve_stock_pool_order_price( rule, &symbol, quote.last_price, OrderSide::Sell, stock_pool_instrument_rules(quote)?.price_tick, )?; row.order_type = Some(kind); row.limit_price = price; row.source_intent .get_or_insert_with(|| format!("stock_pool:{generation}:{symbol}:sell")); } else if cap.blocked_by_t1 { row.target_quantity = quantity; row.delta_quantity = Decimal::ZERO; row.side = None; row.order_type = None; row.limit_price = None; row.source_intent = None; row.status = "DEFERRED_T_PLUS_ONE".into(); row.reason = "指数仓位受可卖数量限制,保留剩余持仓至可交易时处理".into(); } else if desired > Decimal::ZERO { row.target_quantity = quantity; row.delta_quantity = Decimal::ZERO; row.side = None; row.order_type = None; row.limit_price = None; row.source_intent = None; row.status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".into(); row.reason = "指数目标差额不足最小交易单位,无需生成委托".into(); } } } // Do not authorise buys against proceeds that are not settled yet. The // service may re-preview after the sell batch and then submit the buy leg. let available_cash = (account.cash - account.frozen_cash).max(Decimal::ZERO); let mut remaining_cash = available_cash; let confirmed_position_value = current .iter() .filter(|(_, row)| row.0 > Decimal::ZERO) .map(|(symbol, row)| { let price = frozen::valuation(symbol, "e_map, &constraints.frozen_positions)?; Ok(row.0 * price) }) .collect::, String>>()? .into_iter() .sum::(); let mut position_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10000) - confirmed_position_value) .max(Decimal::ZERO); let mut occupied_position_slots = current.values().filter(|row| row.0 > Decimal::ZERO).count(); for row in rows .iter_mut() .filter(|row| row.side == Some(OrderSide::Buy)) { let price = stock_pool_plan_price(row, "e_map, OrderSide::Buy)?; let quote = quote_map .get(&row.symbol) .ok_or_else(|| format!("{} missing current execution quote", row.symbol))?; let (step, minimum_buy) = order_quantity_rules(quote)?; let cost = |quantity: Decimal| { 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 allocation_quantity = max_affordable_buy_quantity_with_cost( own_budget, row.delta_quantity, step, minimum_buy, &cost, )?; if allocation_quantity < minimum_buy { row.status = "BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED".into(); row.reason = "含费用目标预算不足最小交易单位,无需生成委托".into(); row.side = None; row.delta_quantity = Decimal::ZERO; row.target_quantity = row.current_quantity; continue; } if row.current_quantity == Decimal::ZERO && occupied_position_slots >= capacity { row.status = "DEFERRED_POSITION_SLOTS".into(); row.reason = "实际持仓席位尚未释放,卖出成交后重新计算买入".into(); row.side = None; row.delta_quantity = Decimal::ZERO; row.target_quantity = row.current_quantity; continue; } if cost(minimum_buy)? > position_budget { row.status = "DEFERRED_POSITION_BUDGET".into(); row.reason = "实际持仓仍占用目标资金预算,卖出成交后重新计算买入".into(); row.side = None; row.delta_quantity = Decimal::ZERO; row.target_quantity = row.current_quantity; continue; } let max_quantity = max_affordable_buy_quantity_with_cost( remaining_cash.min(own_budget).min(position_budget), row.delta_quantity, step, minimum_buy, &cost, )?; if max_quantity < minimum_buy { row.status = "BLOCKED_ACCOUNT".to_string(); row.reason = "可用资金不足以满足该证券首笔最小交易单位".to_string(); row.side = None; row.delta_quantity = Decimal::ZERO; row.target_quantity = row.current_quantity; continue; } if max_quantity < row.delta_quantity { row.delta_quantity = max_quantity; row.target_quantity = row.current_quantity + max_quantity; if max_quantity < allocation_quantity { row.status = "REDUCE_TO_ALLOWED_QUANTITY".to_string(); row.reason = "按当前可用资金缩量;卖出资金确认后需重新预览".to_string(); } } remaining_cash -= cost(row.delta_quantity)?; position_budget -= cost(row.delta_quantity)?; if row.current_quantity == Decimal::ZERO { occupied_position_slots += 1; } } estimated_buy_amount = rows .iter() .filter(|row| row.side == Some(OrderSide::Buy)) .map(|row| { let price = stock_pool_plan_price(row, "e_map, OrderSide::Buy)?; Ok(row.delta_quantity * price + fee_for(&row.symbol, OrderSide::Buy, row.delta_quantity * price)?) }) .collect::, String>>()? .into_iter() .sum(); estimated_sell_amount = rows .iter() .filter(|row| row.side == Some(OrderSide::Sell)) .map(|row| { let price = stock_pool_plan_price(row, "e_map, OrderSide::Sell)?; let gross = row.delta_quantity.abs() * price; Ok((gross - fee_for(&row.symbol, OrderSide::Sell, gross)?).max(Decimal::ZERO)) }) .collect::, String>>()? .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, estimated_buy_amount, estimated_sell_amount, estimated_cash_after, requested_invest_ratio_bps: invest_ratio_bps, effective_invest_ratio_bps, retained_slots: reserved_protected_slots, }) } fn stock_pool_plan_price( row: &StockPoolPlanRow, quotes: &HashMap, side: OrderSide, ) -> Result { let quote = quotes .get(&row.symbol) .ok_or_else(|| format!("{} execution estimate missing", row.symbol))?; let price = row .limit_price .or(if side == OrderSide::Buy { quote.buy_sizing_price } else { quote.sell_sizing_price }) .unwrap_or(quote.last_price); if price <= Decimal::ZERO { return Err(format!("{} execution estimate invalid", row.symbol)); } Ok(price) } pub fn normalize_stock_pool_members( members: &[StockPoolMemberSpec], ) -> Result, String> { let mut seen = BTreeSet::new(); let mut result = Vec::with_capacity(members.len()); for (index, member) in members.iter().enumerate() { let symbol = normalize_stock_symbol(&member.symbol) .ok_or_else(|| format!("invalid stock pool symbol: {}", member.symbol))?; if !seen.insert(symbol.clone()) { return Err(format!("stock pool contains duplicate symbol: {symbol}")); } if let Some(weight) = member.target_weight_bps && !(0..=10_000).contains(&weight) { return Err(format!("{symbol}.target_weight_bps is out of range")); } for (name, ratio) in [ ("stop_loss", member.stop_loss), ("take_profit", member.take_profit), ] { if let Some(value) = ratio && (value < Decimal::ZERO || value >= Decimal::ONE) { return Err(format!("{symbol}.{name} is out of range")); } } let recommendation_reason = member.recommendation_reason.trim().to_string(); if recommendation_reason.chars().count() > 1000 { return Err(format!( "{symbol}.recommendation_reason exceeds 1000 characters" )); } let mut normalized = member.clone(); normalized.symbol = symbol; normalized.requested_order = index as i32; normalized.recommendation_reason = recommendation_reason; result.push(normalized); } let explicit = result .iter() .filter_map(|item| item.target_weight_bps) .collect::>(); if !explicit.is_empty() && explicit.len() != result.len() { return Err("explicit stock pool weights must be set for every member".to_string()); } if explicit.iter().sum::() > 10_000 { return Err("stock pool weights exceed 10000 bps".to_string()); } Ok(result) } pub fn normalize_stock_symbol(raw: &str) -> Option { let mut value = raw.trim().to_ascii_uppercase(); for (from, to) in [ (".XSHG", ".SH"), (".XSHE", ".SZ"), (".SHSE", ".SH"), (".SZSE", ".SZ"), (".BJSE", ".BJ"), (".XBE", ".BJ"), ] { value = value.replace(from, to); } if !value.contains('.') && value.len() == 6 && value.chars().all(|ch| ch.is_ascii_digit()) { let suffix = if ["600", "601", "603", "605", "688", "689"] .iter() .any(|prefix| value.starts_with(prefix)) { ".SH" } else if ["000", "001", "002", "003", "300", "301"] .iter() .any(|prefix| value.starts_with(prefix)) { ".SZ" } else if ["43", "83", "87", "88", "92"] .iter() .any(|prefix| value.starts_with(prefix)) { ".BJ" } else { "" }; value.push_str(suffix); } let (code, exchange) = value.split_once('.')?; if code.len() != 6 || !code.chars().all(|ch| ch.is_ascii_digit()) || !matches!(exchange, "SH" | "SZ" | "BJ") { return None; } Some(format!("{code}.{exchange}")) } fn normalize_symbol_list(values: &[String]) -> Result, String> { let mut result = Vec::new(); let mut seen = BTreeSet::new(); for value in values { if let Some(symbol) = normalize_stock_symbol(value) { if seen.insert(symbol.clone()) { result.push(symbol); } } else if !value.trim().is_empty() { return Err(format!("invalid stock pool symbol: {value}")); } } Ok(result) } fn normalize_symbol_set(values: &[String]) -> Result, String> { Ok(normalize_symbol_list(values)?.into_iter().collect()) } pub fn stock_pool_execution_quote_symbols( requested_symbols: &[String], positions: &[Position], ) -> Vec { let mut symbols = requested_symbols .iter() .filter_map(|symbol| normalize_stock_symbol(symbol)) .collect::>(); symbols.extend( positions .iter() .filter(|position| position.quantity > Decimal::ZERO) .filter_map(|position| normalize_stock_symbol(&position.symbol)), ); symbols.into_iter().collect() } pub fn resolve_stock_pool_order_price( rule: &StockPoolExecutionRule, symbol: &str, reference_price: Decimal, side: OrderSide, price_tick: Decimal, ) -> Result<(String, Option), String> { if reference_price <= Decimal::ZERO || price_tick <= Decimal::ZERO || price_tick > Decimal::ONE { return Err(format!("{symbol} reference price is invalid")); } let symbol = normalize_stock_symbol(symbol).ok_or_else(|| format!("invalid symbol {symbol}"))?; match rule.pricing_mode.as_str() { POOL_PRICE_FIRST_TICK | POOL_PRICE_CONDITION_THEN_MARKET => { Ok(("market".to_string(), None)) } POOL_PRICE_FIXED_LIMIT => { let price = rule .fixed_prices .get(&symbol) .copied() .or(rule.fixed_price) .ok_or_else(|| format!("fixed_limit pricing has no price for {symbol}"))?; if price <= Decimal::ZERO || (price / price_tick).fract() != Decimal::ZERO { return Err(format!( "fixed_limit price for {symbol} is not aligned with price_tick={price_tick}" )); } Ok(("limit".to_string(), Some(price))) } POOL_PRICE_OPENING_AUCTION | POOL_PRICE_FORMULA_LIMIT | POOL_PRICE_CONDITION_THEN_LIMIT => { let offset = if side == OrderSide::Buy { rule.buy_offset_bps } else { rule.sell_offset_bps }; let raw_price = reference_price * (Decimal::ONE + Decimal::from(offset) / Decimal::from(10_000)); let price = if side == OrderSide::Buy { (raw_price / price_tick).ceil() * price_tick } else { (raw_price / price_tick).floor() * price_tick }; if price <= Decimal::ZERO { return Err(format!("{symbol} protection price is invalid")); } Ok(("limit".to_string(), Some(price))) } mode => Err(format!("stock pool pricing_mode={mode} is not supported")), } } fn floor_step(value: Decimal, step: Decimal) -> Decimal { if value <= Decimal::ZERO || step <= Decimal::ZERO { return Decimal::ZERO; } (value / step).floor() * step } fn commission_for_notional( notional: Decimal, commission_rate: Decimal, minimum_commission: Decimal, ) -> Decimal { if notional <= Decimal::ZERO { return Decimal::ZERO; } (notional * commission_rate).max(minimum_commission) } fn max_affordable_buy_quantity_with_cost( cash: Decimal, requested: Decimal, step: Decimal, minimum: Decimal, cost: &dyn Fn(Decimal) -> Result, ) -> Result { if cash <= Decimal::ZERO || requested <= Decimal::ZERO || step <= Decimal::ZERO { return Ok(Decimal::ZERO); } let mut low = Decimal::ZERO; let mut high = floor_step(requested, step); while high - low > step { let mut mid = floor_step((low + high) / Decimal::from(2), step); if mid <= low { mid = low + step; } if mid >= minimum && cost(mid)? <= cash { low = mid; } else { high = mid - step; } } Ok(if high >= minimum && cost(high)? <= cash { high } else if low >= minimum && cost(low)? <= cash { low } else { Decimal::ZERO }) } fn order_quantity_rules(snapshot: &MarketSnapshot) -> Result<(Decimal, Decimal), String> { let rules = stock_pool_instrument_rules(snapshot)?; Ok((rules.quantity_step, rules.minimum_buy_quantity)) } 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(), Some(value) => serde_json::from_value::(value.clone()) .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!( "stock pool execution_rule.schema_version must be {}", STOCK_POOL_SCHEMA_VERSION )); } if !matches!( rule.trigger_mode.as_str(), POOL_TRIGGER_FIRST_TICK | POOL_TRIGGER_TIME_WINDOW | POOL_TRIGGER_CONDITION | POOL_TRIGGER_SCHEDULED_BAR ) { return Err(format!( "stock pool trigger_mode={} is not supported", rule.trigger_mode )); } if !matches!( rule.pricing_mode.as_str(), POOL_PRICE_FIRST_TICK | POOL_PRICE_OPENING_AUCTION | POOL_PRICE_FIXED_LIMIT | POOL_PRICE_FORMULA_LIMIT | POOL_PRICE_CONDITION_THEN_LIMIT | POOL_PRICE_CONDITION_THEN_MARKET ) { return Err(format!( "stock pool pricing_mode={} is not supported", rule.pricing_mode )); } if rule.pricing_mode == POOL_PRICE_OPENING_AUCTION { return Err( "stock pool opening_auction pricing requires a verified auction quote and is not available" .to_string(), ); } if !matches!( rule.sell_trigger_mode.as_str(), POOL_SELL_TARGET_DELTA | POOL_SELL_CONDITION ) { return Err(format!( "stock pool sell_trigger_mode={} is not supported", rule.sell_trigger_mode )); } for (name, value) in [ ("freeze_time", rule.freeze_time.as_str()), ("window_start", rule.window_start.as_str()), ("window_end", rule.window_end.as_str()), ] { if !is_hhmm(value) { return Err(format!("stock pool {name} must use HH:MM")); } } if rule.freeze_time > rule.window_start || rule.window_start >= rule.window_end { return Err("stock pool execution window is not ordered".to_string()); } if rule.trigger_mode != POOL_TRIGGER_SCHEDULED_BAR { let start = parse_hhmm_minutes(&rule.window_start).expect("validated HH:MM"); let end = parse_hhmm_minutes(&rule.window_end).expect("validated HH:MM"); if !(start..end).any(stock_pool_is_trading_minute) { return Err( "stock pool execution window has no continuous trading minutes".to_string(), ); } } if rule.trigger_mode == POOL_TRIGGER_CONDITION || rule.pricing_mode == POOL_PRICE_CONDITION_THEN_LIMIT || rule.pricing_mode == POOL_PRICE_CONDITION_THEN_MARKET { if rule.buy_condition.trim().is_empty() && !secondary_buy_condition { return Err( "stock pool condition trigger requires a buy quote, factor or event condition" .to_string(), ); } } if !rule.buy_condition.trim().is_empty() && parse_stock_pool_condition(&rule.buy_condition).is_none() { 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 && !independent_sell_condition) || (!rule.sell_condition.trim().is_empty() && parse_stock_pool_condition(&rule.sell_condition).is_none()) { return Err( "stock pool condition sell mode requires a supported sell_condition".to_string(), ); } } else if !rule.sell_condition.trim().is_empty() { return Err("sell_condition requires sell_trigger_mode=condition".to_string()); } if rule.pricing_mode == POOL_PRICE_FIXED_LIMIT && rule.fixed_price.is_none() && rule.fixed_prices.is_empty() { return Err("fixed_limit pricing requires fixed_price or fixed_prices".to_string()); } let mut normalized_fixed_prices = BTreeMap::new(); for (symbol, price) in &rule.fixed_prices { let Some(normalized_symbol) = normalize_stock_symbol(symbol) else { return Err(format!("fixed_prices contains invalid value for {symbol}")); }; if *price <= Decimal::ZERO { return Err(format!("fixed_prices contains invalid value for {symbol}")); } normalized_fixed_prices.insert(normalized_symbol, *price); } rule.fixed_prices = normalized_fixed_prices; if let Some(price) = rule.fixed_price { if price <= Decimal::ZERO { return Err("fixed_price must be positive".to_string()); } } if !matches!(rule.time_in_force.to_ascii_uppercase().as_str(), "DAY") { return Err("stock pool time_in_force currently only supports DAY".to_string()); } if !matches!( rule.missed_window_policy.as_str(), "reject" | "intraday_catchup" ) { return Err("stock pool missed_window_policy is not supported".to_string()); } if !(1..=1).contains(&rule.max_child_orders) { return Err("stock pool max_child_orders must be 1".to_string()); } if !(-9999..=9999).contains(&rule.buy_offset_bps) || !(-9999..=9999).contains(&rule.sell_offset_bps) { return Err("stock pool price offset is out of range".to_string()); } rule.time_in_force = rule.time_in_force.to_ascii_uppercase(); rule.buy_condition = rule.buy_condition.trim().to_string(); rule.sell_condition = rule.sell_condition.trim().to_string(); Ok(rule) } fn is_hhmm(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 5 && bytes[2] == b':' && bytes[0..2].iter().all(u8::is_ascii_digit) && bytes[3..5].iter().all(u8::is_ascii_digit) && value[0..2].parse::().is_ok_and(|hour| hour < 24) && value[3..5].parse::().is_ok_and(|minute| minute < 60) } pub fn parse_stock_pool_condition(value: &str) -> Option<(String, String, String, Decimal)> { let text = value.trim(); let (scope, expression) = text .split_once(':') .map_or(("all", text), |(scope, expression)| (scope, expression)); // Legacy `all:` meant per-symbol evaluation. Preserve it; actual // aggregation is the explicit buy/sell_condition_scope contract. if !scope.eq_ignore_ascii_case("all") { return None; } let operators = [">=", "<=", "==", "!=", ">", "<"]; let (field, operator, threshold) = operators.iter().find_map(|operator| { let index = expression.find(operator)?; Some(( expression[..index].trim(), *operator, expression[index + operator.len()..].trim(), )) })?; if !matches!( field.to_ascii_lowercase().as_str(), "price" | "last" | "change_pct" | "volume" | "amount" | "bid1" | "ask1" ) { return None; } let value = threshold.parse::().ok()?; if (!field.eq_ignore_ascii_case("change_pct") && value < Decimal::ZERO) || (field.eq_ignore_ascii_case("volume") && value.fract() != Decimal::ZERO) { return None; } Some(( scope.to_ascii_lowercase(), field.to_ascii_lowercase(), operator.to_string(), value, )) } fn parse_hhmm_minutes(value: &str) -> Option { Some(value[0..2].parse::().ok()? * 60 + value[3..5].parse::().ok()?) } pub fn stock_pool_is_trading_minute(minute: u32) -> bool { (9 * 60 + 30..11 * 60 + 30).contains(&minute) || (13 * 60..15 * 60 + 31).contains(&minute) } pub fn stock_pool_condition_matches( condition: &str, quote: &MarketSnapshot, ) -> Result { let Some((_scope, field, operator, threshold)) = parse_stock_pool_condition(condition) else { return Err("stock pool condition is not supported".to_string()); }; let observed = match field.as_str() { "price" | "last" => quote.last_price, "change_pct" => quote .prev_close .filter(|value| *value > Decimal::ZERO) .map(|prev| (quote.last_price / prev - Decimal::ONE) * Decimal::from(100)) .ok_or_else(|| "condition requires prev_close".to_string())?, "volume" => quote .volume .ok_or_else(|| "condition requires volume".to_string())?, "amount" => quote .turnover .ok_or_else(|| "condition requires amount".to_string())?, "bid1" => quote .bid_price_1 .ok_or_else(|| "condition requires bid1".to_string())?, "ask1" => quote .ask_price_1 .ok_or_else(|| "condition requires ask1".to_string())?, _ => return Err("stock pool condition field is not supported".to_string()), }; if (matches!(field.as_str(), "price" | "last" | "bid1" | "ask1") && observed <= Decimal::ZERO) || (matches!(field.as_str(), "volume" | "amount") && observed < Decimal::ZERO) || (field == "change_pct" && quote.last_price <= Decimal::ZERO) { return Err(format!("condition requires valid {field}")); } Ok(match operator.as_str() { ">=" => observed >= threshold, "<=" => observed <= threshold, "==" => observed == threshold, "!=" => observed != threshold, ">" => observed > threshold, "<" => observed < threshold, _ => false, }) } fn quote_condition_results( condition: &str, scope: Option, symbols: &[String], quotes: &HashMap, ) -> Result, String> { if condition.trim().is_empty() { return Ok(symbols .iter() .map(|symbol| (symbol.clone(), true)) .collect()); } let values = symbols .iter() .map(|symbol| { let quote = quotes .get(symbol) .ok_or_else(|| format!("quote condition facts missing:{symbol}"))?; Ok(( symbol.clone(), stock_pool_condition_matches(condition, quote)?, )) }) .collect::, String>>()?; let aggregate = match scope.unwrap_or_default() { QuoteConditionScope::PerSymbol => return Ok(values), QuoteConditionScope::AllTargets => { !values.is_empty() && values.values().all(|value| *value) } QuoteConditionScope::AnyTarget => values.values().any(|value| *value), }; Ok(symbols .iter() .map(|symbol| (symbol.clone(), aggregate)) .collect()) } #[cfg(test)] #[path = "stock_pool_execution_tests.rs"] mod tests; #[path = "stock_pool_frozen.rs"] mod frozen; #[path = "stock_pool_index_cap.rs"] mod index_cap;