fix(stock-pool): preserve exit roles and freeze relative reduction bases

This commit is contained in:
boris
2026-09-12 15:54:33 +08:00
parent 4ac9ee5058
commit ffd23b9920
11 changed files with 398 additions and 43 deletions
@@ -343,6 +343,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.map_err(BacktestError::Execution)?;
constraints.pending_entry_symbols = execution_state.pending_symbols();
constraints.prior_target_weights = execution_state.last_target_weights.clone();
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
let account = pool::AccountSnapshot {
total_equity: contract.frozen_equity,
+2
View File
@@ -3666,6 +3666,8 @@ where
let split_ratio = action.split_ratio();
if (split_ratio - 1.0).abs() > f64::EPSILON {
portfolio.adjust_stock_pool_split(&action.symbol, split_ratio)
.map_err(BacktestError::Execution)?;
let (delta_quantity, quantity_after, average_cost) = {
let position = portfolio
.position_mut_if_exists(&action.symbol)
+21 -2
View File
@@ -624,6 +624,7 @@ pub struct PlatformPositionTargetRule {
pub when_expr: String,
pub remaining_position_bps: u32,
pub reason: String,
pub stock_pool_role: crate::stock_pool_execution::StockPoolExitRole,
}
#[derive(Debug, Clone)]
@@ -10146,6 +10147,22 @@ impl PlatformExprStrategy {
factor_date: NaiveDate,
day: &DayExpressionState,
) -> Result<BTreeMap<String, (u32, String)>, BacktestError> {
let mut targets = BTreeMap::new();
for (_, scoped) in self.current_position_target_rules_by_role(ctx, signal_date, factor_date, day)? {
for (symbol, value) in scoped {
if targets.get(&symbol).is_none_or(|(bps, _)| value.0 < *bps) { targets.insert(symbol, value); }
}
}
Ok(targets)
}
fn current_position_target_rules_by_role(
&self,
ctx: &StrategyContext<'_>,
signal_date: NaiveDate,
factor_date: NaiveDate,
day: &DayExpressionState,
) -> Result<BTreeMap<crate::stock_pool_execution::StockPoolExitRole, BTreeMap<String, (u32, String)>>, BacktestError> {
let mut targets = BTreeMap::new();
if self.config.position_target_rules.is_empty() {
return Ok(targets);
@@ -10160,11 +10177,12 @@ impl PlatformExprStrategy {
if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? {
continue;
}
let replace = targets
let scoped = targets.entry(rule.stock_pool_role).or_insert_with(BTreeMap::new);
let replace = scoped
.get(&position.symbol)
.map_or(true, |(bps, _)| rule.remaining_position_bps < *bps);
if replace {
targets.insert(
scoped.insert(
position.symbol.clone(),
(rule.remaining_position_bps, rule.reason.clone()),
);
@@ -37826,6 +37844,7 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0;
config.rebalance_existing_positions = true;
config.hold_until_exit_enabled = true;
config.position_target_rules = vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5_000,
reason: "factor_reduce_position".to_string(),
+14 -17
View File
@@ -14,6 +14,14 @@ impl PlatformExprStrategy {
.as_ref()
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
.clone();
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|| self.config.position_target_rules.len() != program.exit_signals.len()
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
{
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
}
let mut constraints = pool::stock_pool_constraints_from_configuration(
&program.allocation_policy,
&program.stop_take_policy,
@@ -78,12 +86,11 @@ impl PlatformExprStrategy {
closes,
});
}
let rule = pool::normalize_stock_pool_execution_rule(
let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
Some(&program.timing_policy),
!self.config.buy_filter_expr.trim().is_empty(),
!self.config.stop_loss_expr.trim().is_empty()
|| !self.config.take_profit_expr.trim().is_empty()
|| !self.config.position_target_rules.is_empty(),
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
)
.map_err(BacktestError::Execution)?;
if self.config.in_skip_window(ctx.decision_date) {
@@ -133,23 +140,13 @@ impl PlatformExprStrategy {
}
}
}
let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?;
for symbol in native_exits {
constraints.position_target_bps.insert(symbol, 0);
}
for (symbol, (bps, _)) in
self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)?
{
constraints
.position_target_bps
.entry(symbol)
.and_modify(|old| *old = (*old).min(bps))
.or_insert(bps);
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
}
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
let final_symbols = ranked
.iter()
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
.take(limit)
.cloned()
.collect();
@@ -2339,6 +2339,7 @@ pub fn platform_expr_config_from_spec(
));
}
cfg.position_target_rules.push(PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: when_expr.to_string(),
remaining_position_bps: rule.remaining_position_bps,
reason: rule
@@ -2712,9 +2713,14 @@ pub fn platform_expr_config_from_spec(
}
if let Some(pool)=&spec.stock_pool {
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty();
pool.validate(secondary_buy,secondary_sell)?;
pool.validate(secondary_buy,false)?;
cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
reason: signal.reason.clone(), stock_pool_role: signal.role,
}));
cfg.stock_pool=Some(pool.clone());
cfg.hold_until_exit_enabled=false;
cfg.daily_top_up_enabled=false;
@@ -3456,6 +3462,7 @@ mod tests {
assert_eq!(
cfg.position_target_rules,
vec![PlatformPositionTargetRule {
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
remaining_position_bps: 5000,
reason: "factor_reduce_position".to_string(),
+10
View File
@@ -732,6 +732,16 @@ impl PortfolioState {
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
}
pub(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
let adjusted = self.stock_pool_states.iter()
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
.collect::<Result<BTreeMap<_, _>, String>>()?;
self.stock_pool_states = adjusted;
Ok(())
}
pub fn initial_cash(&self) -> f64 {
self.initial_cash.to_f64()
}
+111 -19
View File
@@ -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, &current)?;
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,
&quote_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())
{
@@ -870,6 +870,64 @@ fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate()
}
}
#[test]
fn typed_exit_roles_merge_only_satisfied_ordinary_actions_with_independent_risk() {
for risk in [None,Some(0),Some(5000)] {
for ordinary in [None,Some(0),Some(7500)] {
for quote in ["","price<9","price>9"] {
for locked in [false,true] {
for closable in [0,400,1000] {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":quote})),false,true,true).unwrap();
let mut constraints=StockPoolDecisionConstraints {portfolio_policy:Some(StockPoolPortfolioPolicy{schema_version:1,membership:MembershipPolicy::RetainHoldings,rebalance_weights:false}),..Default::default()};
if let Some(target)=ordinary {constraints.position_target_bps.insert(symbol(1),target);}
if let Some(target)=risk {constraints.independent_position_target_bps.insert(symbol(1),target);}
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission{sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
let mut held=position(1);held.closable_quantity=closable.into();
let plan=condition_plan(&selection(1,1),&rule,&[held],&quotes(1),&constraints);
assert_eq!(plan.rows.len(),1,"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
let ordinary=if quote=="price<9" {None} else {ordinary};
let target_bps=risk.into_iter().chain(ordinary).min().unwrap_or(10000);
let desired=if target_bps==0 {0} else {(1000*target_bps/10000)/100*100};
let sold=if locked {0} else {(1000-desired).min(closable)};
assert_eq!(plan.rows[0].delta_quantity,-Decimal::from(sold),"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
}
}
}
}
}
}
#[test]
fn risk_only_configuration_never_turns_into_an_unconditional_ordinary_exit() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition"})),false,false,true).unwrap();
let mut constraints=StockPoolDecisionConstraints::default();
let hold=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_ne!(hold.rows[0].side,Some(OrderSide::Sell),"a risk-only configuration must not manufacture an exit: {hold:?}");
constraints.independent_position_target_bps.insert(symbol(1),5000);
let exit=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&constraints);
assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}");
let unheld=condition_plan(&selection(1,1),&rule,&[],&quotes(1),&constraints);
assert_eq!(unheld.rows[0].side,Some(OrderSide::Buy),"an exit-only rule must not secretly become a selection/buy filter: {unheld:?}");
}
#[test]
fn quote_only_exit_still_works_when_independent_risk_rules_are_configured() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>9"})),false,false,true).unwrap();
let plan=condition_plan(&selection(1,1),&rule,&[position(1)],&quotes(1),&StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.len(),1);assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
}
#[test]
fn independent_full_exit_has_no_ordinary_quote_dependency_but_partial_risk_does_not_fake_missing_facts() {
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>100"})),false,true,true).unwrap();
let mut market=quotes(1);market[0].volume=None;
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),0)]),independent_position_target_bps:BTreeMap::from([(symbol(1),0)]),..Default::default()};
let complete=condition_plan(&selection(1,1),&rule,&[position(1)],&market,&constraints);
assert_eq!(complete.rows[0].delta_quantity,Decimal::from(-1000));
constraints.independent_position_target_bps.insert(symbol(1),5000);
assert!(condition_plan_result(&selection(1,1),&rule,&[position(1)],&market,&constraints).unwrap_err().contains("requires volume"));
}
#[test]
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
let mut constraints = StockPoolDecisionConstraints::default();
+86 -2
View File
@@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress {
pub completion_quantity: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolPositionActionBasis {
pub generation: String,
pub first_execution_date: NaiveDate,
pub quantity: Decimal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StockPoolExecutionState {
@@ -34,6 +42,10 @@ pub struct StockPoolExecutionState {
pub last_target_weights: BTreeMap<String, i32>,
/// First signal excluding an actually held member; not an acquisition date.
pub removed_since: BTreeMap<String, NaiveDate>,
/// Signal progress, not a fill or holding-period fact. Kept across retries
/// and later execution sessions until a new generation supersedes it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
}
pub struct StockPoolGoalObservation<'a> {
@@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState {
entries: BTreeMap::new(),
last_target_weights: BTreeMap::new(),
removed_since: BTreeMap::new(),
position_action_bases: BTreeMap::new(),
}
}
}
@@ -62,6 +75,7 @@ impl StockPoolExecutionState {
if self.schema_version != 1
|| self.entries.len() > 10000
|| self.removed_since.len() > 10000
|| self.position_action_bases.len() > 10000
{
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
}
@@ -70,6 +84,7 @@ impl StockPoolExecutionState {
.keys()
.chain(self.removed_since.keys())
.chain(self.last_target_weights.keys())
.chain(self.position_action_bases.keys())
{
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
return Err("stock_pool_execution_state_invalid_symbol".into());
@@ -97,6 +112,12 @@ impl StockPoolExecutionState {
{
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
}
if self.position_action_bases.values().any(|basis| {
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
}) {
return Err("stock_pool_execution_state_invalid_action_basis".into());
}
Ok(())
}
@@ -186,7 +207,7 @@ impl StockPoolExecutionState {
self.record_targets(
decision_date,
generation,
plan.rows.iter().map(|row| StockPoolGoalObservation {
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
symbol: &row.symbol,
target_weight_bps: row.target_weight_bps,
target_value: row.target_value,
@@ -194,7 +215,67 @@ impl StockPoolExecutionState {
target_quantity: row.target_quantity,
status: &row.status,
}),
)
)?.record_position_action_bases(generation, &plan.position_action_bases)
}
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
self.position_action_bases.iter()
.filter(|(_, basis)| basis.generation == generation)
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
.collect()
}
/// A verified split changes the share unit, not the intended reduction or
/// entry completion. Never infer a split from a changed holding quantity.
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
self.validate()?;
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
return Err("stock_pool_execution_state_split_invalid".into());
}
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
let mut next = self.clone();
if let Some(entry) = next.entries.get_mut(symbol) {
if let Some(quantity) = entry.completion_quantity {
let quantity = scale(quantity)?;
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
}
}
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
basis.quantity = scale(basis.quantity)?;
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
}
next.validate()?;
Ok(next)
}
pub fn record_position_action_bases(
&self,
generation: &str,
quantities: &BTreeMap<String, Decimal>,
) -> Result<Self, String> {
self.validate()?;
if generation.trim().is_empty() {
return Err("stock_pool_execution_state_action_generation_missing".into());
}
let first_execution_date = self.last_execution_date
.ok_or("stock_pool_execution_state_action_clock_missing")?;
let mut next = self.clone();
next.position_action_bases.retain(|_, basis| basis.generation == generation);
for (symbol, quantity) in quantities {
if let Some(basis) = next.position_action_bases.get(symbol) {
if basis.quantity != *quantity {
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
}
} else {
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
generation: generation.into(), first_execution_date, quantity: *quantity,
});
}
}
next.validate()?;
Ok(next)
}
pub fn record_targets<'a>(
@@ -213,6 +294,9 @@ impl StockPoolExecutionState {
}
let mut next = self.clone();
for row in rows {
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
continue;
}
if row.target_weight_bps > 0 {
next.last_target_weights
.insert(row.symbol.into(), row.target_weight_bps);