fix(stock-pool): preserve exit roles and freeze relative reduction bases
This commit is contained in:
@@ -39,6 +39,22 @@ pub enum QuoteConditionScope {
|
||||
AnyTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StockPoolExitRole {
|
||||
OrdinarySell,
|
||||
RiskExit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StockPoolExitSignal {
|
||||
pub role: StockPoolExitRole,
|
||||
pub when_expr: String,
|
||||
pub remaining_position_bps: u32,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> {
|
||||
let object = policy
|
||||
.as_object()
|
||||
@@ -399,6 +415,8 @@ pub struct StockPoolExecutionRule {
|
||||
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"
|
||||
@@ -476,6 +494,10 @@ pub struct StockPoolDecisionConstraints {
|
||||
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>,
|
||||
@@ -508,6 +530,8 @@ pub struct StockPoolPlanRow {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StockPoolPlan {
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub position_action_bases: BTreeMap<String, Decimal>,
|
||||
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
|
||||
pub rows: Vec<StockPoolPlanRow>,
|
||||
pub budget: Decimal,
|
||||
@@ -549,6 +573,8 @@ pub struct StockPoolProgram {
|
||||
pub timing_policy: Value,
|
||||
pub stop_take_policy: Value,
|
||||
pub out_of_pool_policy: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exit_signals: Vec<StockPoolExitSignal>,
|
||||
}
|
||||
|
||||
impl StockPoolProgram {
|
||||
@@ -562,10 +588,19 @@ impl StockPoolProgram {
|
||||
normalize_stock_pool_members(&self.members)?;
|
||||
stock_pool_funding_from_configuration(&self.allocation_policy)?;
|
||||
stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?;
|
||||
normalize_stock_pool_execution_rule(
|
||||
let mut identities = BTreeSet::new();
|
||||
for signal in &self.exit_signals {
|
||||
if signal.when_expr.trim().is_empty() || signal.reason.trim().is_empty() || signal.remaining_position_bps >= 10000 {
|
||||
return Err("stock_pool_exit_signal_invalid".into());
|
||||
}
|
||||
let identity = serde_json::to_string(signal).map_err(|error| error.to_string())?;
|
||||
if !identities.insert(identity) { return Err("stock_pool_exit_signal_duplicate".into()); }
|
||||
}
|
||||
normalize_stock_pool_execution_rule_with_exit_roles(
|
||||
Some(&self.timing_policy),
|
||||
secondary_buy,
|
||||
secondary_sell,
|
||||
secondary_sell || self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::OrdinarySell),
|
||||
self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::RiskExit),
|
||||
)?;
|
||||
if !matches!(
|
||||
self.out_of_pool_policy.as_str(),
|
||||
@@ -583,6 +618,7 @@ impl Default for StockPoolExecutionRule {
|
||||
buy_condition_scope: None,
|
||||
sell_condition_scope: None,
|
||||
secondary_sell_condition: false,
|
||||
independent_sell_condition: false,
|
||||
automatic_trade_protection: Default::default(),
|
||||
schema_version: STOCK_POOL_SCHEMA_VERSION,
|
||||
auto_execute: true,
|
||||
@@ -671,12 +707,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
}
|
||||
// Validate source targets before a stronger stop/expiry can replace them.
|
||||
// Otherwise an invalid ratio could be hidden by target consolidation.
|
||||
for (symbol, target) in &constraints.position_target_bps {
|
||||
for (symbol, target) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
|
||||
if *target >= 10_000 {
|
||||
return Err(format!("factor position target for {symbol} must be below 10000 bps"));
|
||||
}
|
||||
}
|
||||
let mut effective_position_targets = constraints.position_target_bps.clone();
|
||||
for (symbol, target) in &constraints.independent_position_target_bps {
|
||||
effective_position_targets.entry(symbol.clone()).and_modify(|current| *current = (*current).min(*target)).or_insert(*target);
|
||||
}
|
||||
for (symbol, permission) in &constraints.automatic_permissions {
|
||||
if permission.max_holding_exit {
|
||||
effective_position_targets.insert(symbol.clone(), 0);
|
||||
@@ -771,6 +810,19 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
if quote_map.len() != quotes.len() {
|
||||
return Err("duplicate or invalid stock pool execution quotes".into());
|
||||
}
|
||||
let declared_symbols = normalized_members.iter().map(|member| member.symbol.as_str()).collect::<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);
|
||||
@@ -880,6 +932,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
let mut quote_sell_exits = BTreeSet::new();
|
||||
let mut sell_condition_denials = BTreeSet::new();
|
||||
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
||||
let ordinary_enabled = !rule.sell_condition.trim().is_empty() || rule.secondary_sell_condition;
|
||||
// Ordinary sell predicates only depend on positions participating in
|
||||
// that stage. Independent stops/expiry and protected holdings were
|
||||
// already decided above; unrelated quote fields must not block them.
|
||||
@@ -889,24 +942,29 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
row.0 > Decimal::ZERO
|
||||
&& !protected_positions.contains(*symbol)
|
||||
&& !global_stop_hits.contains(*symbol)
|
||||
&& constraints.independent_position_target_bps.get(*symbol) != Some(&0)
|
||||
&& !constraints.automatic_permissions.get(*symbol)
|
||||
.is_some_and(|permission| permission.max_holding_exit)
|
||||
})
|
||||
.map(|(symbol, _)| symbol.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let qualified = quote_condition_results(
|
||||
let qualified = if ordinary_enabled { quote_condition_results(
|
||||
&rule.sell_condition,
|
||||
rule.sell_condition_scope,
|
||||
&held,
|
||||
"e_map,
|
||||
)?;
|
||||
)? } else { BTreeMap::new() };
|
||||
for symbol in held {
|
||||
let permitted = qualified.get(&symbol) == Some(&true)
|
||||
let permitted = ordinary_enabled && qualified.get(&symbol) == Some(&true)
|
||||
&& (!rule.secondary_sell_condition
|
||||
|| constraints.position_target_bps.contains_key(&symbol));
|
||||
if !permitted {
|
||||
sell_condition_denials.insert(symbol.clone());
|
||||
effective_position_targets.remove(&symbol);
|
||||
if let Some(target) = constraints.independent_position_target_bps.get(&symbol) {
|
||||
effective_position_targets.insert(symbol.clone(), *target);
|
||||
} else {
|
||||
sell_condition_denials.insert(symbol.clone());
|
||||
}
|
||||
} else if !rule.secondary_sell_condition {
|
||||
quote_sell_exits.insert(symbol.clone());
|
||||
effective_position_targets.insert(symbol, 0);
|
||||
@@ -957,6 +1015,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
let normalized_same_day_sold =
|
||||
normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<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()
|
||||
@@ -1346,14 +1405,6 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
"factor position-action symbol {symbol} is outside candidates and managed holdings"
|
||||
));
|
||||
}
|
||||
if selection.final_symbols.contains(symbol)
|
||||
&& !maximum_holding_exits.contains(symbol)
|
||||
&& !quote_sell_exits.contains(symbol)
|
||||
{
|
||||
return Err(format!(
|
||||
"factor position-action symbol {symbol} cannot remain in final selection"
|
||||
));
|
||||
}
|
||||
let current_quantity = current
|
||||
.get(symbol)
|
||||
.map(|value| value.0)
|
||||
@@ -1375,10 +1426,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
floor_step(
|
||||
current_quantity * Decimal::from(*target_bps) / Decimal::from(10_000),
|
||||
constraints.position_action_bases.get(symbol).copied().unwrap_or(current_quantity)
|
||||
* Decimal::from(*target_bps) / Decimal::from(10_000),
|
||||
step,
|
||||
)
|
||||
};
|
||||
}.min(current_quantity);
|
||||
let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO);
|
||||
let executable = if *target_bps == 0 {
|
||||
closable_quantity.min(current_quantity).max(Decimal::ZERO)
|
||||
@@ -1392,13 +1444,33 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
if current_quantity == Decimal::ZERO {
|
||||
(
|
||||
"FACTOR_EXIT_ALREADY_SATISFIED",
|
||||
"生产因子持仓动作命中,当前无持仓",
|
||||
"持仓退出规则命中,当前无持仓",
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else if desired_reduction == Decimal::ZERO {
|
||||
(
|
||||
"FACTOR_EXIT_ALREADY_SATISFIED",
|
||||
"本次信号的持仓退出目标已达到,不重复减仓",
|
||||
Decimal::ZERO,
|
||||
current_quantity,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else if executable == Decimal::ZERO && closable_quantity >= desired_reduction {
|
||||
(
|
||||
"BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED",
|
||||
"目标持仓差额不足最小交易单位,无需重复委托",
|
||||
Decimal::ZERO,
|
||||
current_quantity,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else if executable == Decimal::ZERO {
|
||||
(
|
||||
"DEFERRED_T_PLUS_ONE",
|
||||
@@ -1429,6 +1501,8 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
"卖出行情条件命中"
|
||||
} else if stop_take_exits.contains(symbol) {
|
||||
"止损/止盈触发,覆盖较弱的减仓目标"
|
||||
} else if constraints.independent_position_target_bps.get(symbol) == Some(target_bps) {
|
||||
"独立风险退出条件命中"
|
||||
} else if *target_bps == 0 {
|
||||
"生产因子退出条件命中"
|
||||
} else {
|
||||
@@ -1913,7 +1987,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
||||
.into_iter()
|
||||
.sum();
|
||||
let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount;
|
||||
let position_action_bases = rows.iter()
|
||||
.filter(|row| effective_position_targets.get(&row.symbol).is_some_and(|bps| *bps > 0)
|
||||
&& row.current_quantity > Decimal::ZERO
|
||||
&& row.status != "AUTOMATIC_TRADE_PROTECTED"
|
||||
&& !constraints.frozen_positions.contains_key(&row.symbol))
|
||||
.map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity)))
|
||||
.collect();
|
||||
Ok(StockPoolPlan {
|
||||
position_action_bases,
|
||||
market_timing,
|
||||
rows,
|
||||
budget,
|
||||
@@ -2190,6 +2272,15 @@ pub fn normalize_stock_pool_execution_rule(
|
||||
raw: Option<&Value>,
|
||||
secondary_buy_condition: bool,
|
||||
secondary_sell_condition: bool,
|
||||
) -> Result<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(),
|
||||
@@ -2197,6 +2288,7 @@ pub fn normalize_stock_pool_execution_rule(
|
||||
.map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?,
|
||||
};
|
||||
rule.secondary_sell_condition = secondary_sell_condition;
|
||||
rule.independent_sell_condition = independent_sell_condition;
|
||||
rule.automatic_trade_protection.validate()?;
|
||||
if rule.schema_version != STOCK_POOL_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
@@ -2283,7 +2375,7 @@ pub fn normalize_stock_pool_execution_rule(
|
||||
return Err("stock pool buy_condition is not supported".to_string());
|
||||
}
|
||||
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
||||
if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition)
|
||||
if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition && !independent_sell_condition)
|
||||
|| (!rule.sell_condition.trim().is_empty()
|
||||
&& parse_stock_pool_condition(&rule.sell_condition).is_none())
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user