Files
fidc-backtest-engine/crates/fidc-core/src/stock_pool_execution.rs
T

2581 lines
99 KiB
Rust

//! 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<Option<usize>, String> {
let object = policy
.as_object()
.ok_or("allocation_policy must be an object")?;
let mut observed: Option<Option<usize>> = 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::<Decimal>()
.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::<usize>()
.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<Self, String> {
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<Option<Decimal>, String> {
let mut result: Option<Option<Decimal>> = 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::<Decimal>()
.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::<i32>()
.map_err(|_| "investment ratio out of range")?,
cash,
))
}
pub fn stock_pool_constraints_from_configuration(
allocation: &Value,
stops: &Value,
) -> Result<StockPoolDecisionConstraints, String> {
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<Option<&Value>, 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<Option<Decimal>, 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::<Decimal>()
.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<Decimal>,
pub volume: Option<Decimal>,
pub turnover: Option<Decimal>,
pub bid_price_1: Option<Decimal>,
pub ask_price_1: Option<Decimal>,
pub is_kcb: Option<bool>,
pub instrument_rules: Option<StockPoolInstrumentRules>,
/// Explicit execution-adapter estimates; omission means the declared last
/// price model, never replacement of an invalid supplied value.
pub buy_sizing_price: Option<Decimal>,
pub sell_sizing_price: Option<Decimal>,
}
#[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<StockPoolInstrumentRules, String> {
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<i32>,
#[serde(default)]
pub stop_loss: Option<Decimal>,
#[serde(default)]
pub take_profit: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct StockPoolExecutionRule {
#[serde(default, alias = "buyConditionScope")]
pub buy_condition_scope: Option<QuoteConditionScope>,
#[serde(default, alias = "sellConditionScope")]
pub sell_condition_scope: Option<QuoteConditionScope>,
#[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<Decimal>,
#[serde(alias = "fixedPrices")]
pub fixed_prices: BTreeMap<String, Decimal>,
#[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<String>,
pub normal_trading_symbols: Vec<String>,
pub risk_eligible_symbols: Vec<String>,
pub final_symbols: Vec<String>,
#[serde(default)]
pub exclusion_reasons: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub inherited_from_generation: Option<String>,
#[serde(default)]
pub explicit_empty: bool,
#[serde(default)]
pub generation: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StockPoolDecisionConstraints {
pub execution_date: Option<NaiveDate>,
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
pub prior_target_weights: BTreeMap<String, i32>,
pub pending_entry_symbols: BTreeSet<String>,
pub next_day_outside_exit_symbols: BTreeSet<String>,
pub market_timing_policy: Option<crate::stock_pool_index_policy::MarketTimingPolicy>,
pub market_timing_input: Option<crate::stock_pool_index_policy::MarketTimingInput>,
pub portfolio_policy: Option<StockPoolPortfolioPolicy>,
pub target_holding_count: Option<usize>,
pub reserve_cash_slots: usize,
pub default_stop_loss: Option<Decimal>,
pub default_take_profit: Option<Decimal>,
pub position_target_bps: BTreeMap<String, u32>,
pub independent_position_target_bps: BTreeMap<String, u32>,
/// First actually planned holding quantity for this generation. Retries
/// apply percentages to this basis, never to the remaining holding.
pub position_action_bases: BTreeMap<String, Decimal>,
pub buy_denials: BTreeMap<String, Vec<String>>,
pub same_day_sold_symbols: BTreeSet<String>,
pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
}
#[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<OrderSide>,
pub status: String,
pub reason: String,
pub reference_price: Option<Decimal>,
pub order_type: Option<String>,
pub limit_price: Option<Decimal>,
pub source_intent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StockPoolPlan {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, Decimal>,
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
pub rows: Vec<StockPoolPlanRow>,
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<StockPoolMemberSpec>,
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<StockPoolMemberSpec>,
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<StockPoolExitSignal>,
}
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<StockPoolPlan, String> {
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<Decimal, String>>,
) -> Result<StockPoolPlan, String> {
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<Decimal, String> {
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(&quote.symbol).map(|symbol| (symbol, quote)))
.collect::<HashMap<_, _>>();
let mut current = BTreeMap::<String, (Decimal, Decimal, Decimal)>::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::<BTreeSet<_>>();
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, &current)?;
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::<BTreeSet<_>>();
for (symbol, (quantity, _, _)) in &current {
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::<HashMap<_, _>>();
let explicit_weights = normalized_members
.iter()
.filter_map(|member| {
member
.target_weight_bps
.map(|weight| (member.symbol.clone(), weight))
})
.collect::<BTreeMap<_, _>>();
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::<Vec<_>>();
let mut chosen = retained.into_iter().take(capacity).collect::<Vec<_>>();
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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
// A full stop is stricter than a simultaneous relative reduction. Merge
// the target before selecting its single owner, never emit a second exit.
for symbol in &global_stop_hits {
if let Some(target) = effective_position_targets.get_mut(symbol) {
*target = 0;
}
}
let mut quote_sell_exits = BTreeSet::new();
let mut sell_condition_denials = BTreeSet::new();
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
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::<Vec<_>>();
let qualified = if ordinary_enabled { quote_condition_results(
&rule.sell_condition,
rule.sell_condition_scope,
&held,
&quote_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::<BTreeSet<_>>();
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::<Vec<_>>())?;
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::<Vec<_>>();
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::<BTreeMap<_, _>>()
} 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::<BTreeSet<_>>();
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::<Vec<_>>(),
&quote_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::<BTreeSet<_>>()
} 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::<Decimal>()
.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::<i32>()
.max(weights.values().copied().sum())
};
let mut protected_values = protected_positions
.iter()
.map(|symbol| {
let current_value = current[symbol].0
* frozen::valuation(symbol, &quote_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::<Result<BTreeMap<_, _>, 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::<Decimal>();
let free_budget = (budget * Decimal::from(requested_weight_total) / Decimal::from(10_000)
- protected_values.values().copied().sum::<Decimal>())
.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(
&current,
&member_map,
&constraints.automatic_permissions,
&rows,
&quote_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::<HashMap<_, _>>();
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, &quote_map, &constraints.frozen_positions)?;
Ok(row.0 * price)
})
.collect::<Result<Vec<Decimal>, String>>()?
.into_iter()
.sum::<Decimal>();
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, &quote_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, &quote_map, OrderSide::Buy)?;
Ok(row.delta_quantity * price
+ fee_for(&row.symbol, OrderSide::Buy, row.delta_quantity * price)?)
})
.collect::<Result<Vec<Decimal>, 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, &quote_map, OrderSide::Sell)?;
let gross = row.delta_quantity.abs() * price;
Ok((gross - fee_for(&row.symbol, OrderSide::Sell, gross)?).max(Decimal::ZERO))
})
.collect::<Result<Vec<Decimal>, 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<String, &MarketSnapshot>,
side: OrderSide,
) -> Result<Decimal, String> {
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<Vec<StockPoolMemberSpec>, 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::<Vec<_>>();
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::<i32>() > 10_000 {
return Err("stock pool weights exceed 10000 bps".to_string());
}
Ok(result)
}
pub fn normalize_stock_symbol(raw: &str) -> Option<String> {
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<Vec<String>, 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<BTreeSet<String>, String> {
Ok(normalize_symbol_list(values)?.into_iter().collect())
}
pub fn stock_pool_execution_quote_symbols(
requested_symbols: &[String],
positions: &[Position],
) -> Vec<String> {
let mut symbols = requested_symbols
.iter()
.filter_map(|symbol| normalize_stock_symbol(symbol))
.collect::<BTreeSet<_>>();
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<Decimal>), 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<Decimal, String>,
) -> Result<Decimal, String> {
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<StockPoolExecutionRule, String> {
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<StockPoolExecutionRule, String> {
let mut rule = match raw {
None | Some(Value::Null) => StockPoolExecutionRule::default(),
Some(value) => serde_json::from_value::<StockPoolExecutionRule>(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::<u8>().is_ok_and(|hour| hour < 24)
&& value[3..5].parse::<u8>().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::<Decimal>().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<u32> {
Some(value[0..2].parse::<u32>().ok()? * 60 + value[3..5].parse::<u32>().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<bool, String> {
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<QuoteConditionScope>,
symbols: &[String],
quotes: &HashMap<String, &MarketSnapshot>,
) -> Result<BTreeMap<String, bool>, 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::<Result<BTreeMap<_, _>, 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;