2581 lines
99 KiB
Rust
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("e.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, ¤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::<BTreeSet<_>>();
|
|
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::<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,
|
|
"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::<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<_>>(),
|
|
"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::<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, "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::<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(
|
|
¤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::<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, "e_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, "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::<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, "e_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;
|