fix(stock-pool): separate exit ownership and ordinary quote dependencies

This commit is contained in:
boris
2026-09-12 12:18:26 +08:00
parent 099759ae67
commit 809b782c1a
3 changed files with 166 additions and 11 deletions
+22 -10
View File
@@ -866,10 +866,17 @@ 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 {
// 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 && !constraints.frozen_positions.contains_key(*symbol)
row.0 > Decimal::ZERO
&& !protected_positions.contains(*symbol)
&& !global_stop_hits.contains(*symbol)
&& !constraints.automatic_permissions.get(*symbol)
.is_some_and(|permission| permission.max_holding_exit)
})
.map(|(symbol, _)| symbol.clone())
.collect::<Vec<_>>();
@@ -880,14 +887,6 @@ pub fn build_stock_pool_target_plan_with_fee_model(
&quote_map,
)?;
for symbol in held {
if global_stop_hits.contains(&symbol)
|| constraints
.automatic_permissions
.get(&symbol)
.is_some_and(|permission| permission.max_holding_exit)
{
continue;
}
let permitted = qualified.get(&symbol) == Some(&true)
&& (!rule.secondary_sell_condition
|| constraints.position_target_bps.contains_key(&symbol));
@@ -1031,7 +1030,12 @@ pub fn build_stock_pool_target_plan_with_fee_model(
}
let mut planning_symbols = active_symbols;
for symbol in &original_final_symbols {
if rebuy_exclusions.contains(symbol) && !planning_symbols.contains(symbol) {
// 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());
}
}
@@ -1688,6 +1692,14 @@ pub fn build_stock_pool_target_plan_with_fee_model(
}
}
}
// Verify disjoint planning ownership before an index cap can address rows
// by symbol. Never deduplicate emitted intentions or count proceeds twice.
let mut owners = BTreeSet::new();
for row in &rows {
if !owners.insert(row.symbol.as_str()) {
return Err(format!("stock_pool_target_owner_conflict:{}", row.symbol));
}
}
if market_timing.is_some() {
let caps = index_cap::remaining_index_targets(
&current,
@@ -509,6 +509,16 @@ fn condition_plan(
quotes: &[MarketSnapshot],
constraints: &StockPoolDecisionConstraints,
) -> StockPoolPlan {
condition_plan_result(selection, rule, positions, quotes, constraints).unwrap()
}
fn condition_plan_result(
selection: &StockPoolSelection,
rule: &StockPoolExecutionRule,
positions: &[Position],
quotes: &[MarketSnapshot],
constraints: &StockPoolDecisionConstraints,
) -> Result<StockPoolPlan, String> {
let held_value = positions
.iter()
.map(|position| {
@@ -541,7 +551,6 @@ fn condition_plan(
Decimal::ZERO,
Decimal::ZERO,
)
.unwrap()
}
#[test]
@@ -704,6 +713,115 @@ fn native_sell_and_quote_conditions_are_and_but_stop_and_protection_remain_indep
);
}
#[test]
fn independent_stop_does_not_require_unused_ordinary_sell_quote_facts() {
let mut market = quotes(1);
market[0].last_price = 9.into();
market[0].volume = None;
let rule = normalize_stock_pool_execution_rule(
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})),
false,
false,
).unwrap();
let constraints = StockPoolDecisionConstraints {
default_stop_loss: Some(Decimal::new(5, 2)),
..Default::default()
};
let plan = condition_plan(&selection(1, 1), &rule, &[position(1)], &market, &constraints);
let exit = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
assert_eq!(exit.side, Some(OrderSide::Sell), "{plan:?}");
assert_eq!(exit.target_quantity, Decimal::ZERO, "{plan:?}");
}
#[test]
fn ordinary_sell_scope_excludes_independent_exits_and_protected_positions() {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for cause in ["stop_loss", "take_profit", "maximum_holding_exit", "automatic_trade_locked", "buy_fill_protection"] {
let mut market = quotes(2);
market[0].volume = None;
let mut constraints = StockPoolDecisionConstraints::default();
match cause {
"stop_loss" => { market[0].last_price = 9.into(); constraints.default_stop_loss = Some(Decimal::new(5, 2)); },
"take_profit" => { market[0].last_price = 12.into(); constraints.default_take_profit = Some(Decimal::new(10, 2)); },
"maximum_holding_exit" => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { max_holding_exit: true, ..Default::default() }); },
_ => { constraints.automatic_permissions.insert(symbol(1), crate::holding_policy::AutomaticTradePermission { sell_denial: Some(cause), buy_denial: Some(cause), ..Default::default() }); },
}
let rule = normalize_stock_pool_execution_rule(
Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000","sell_condition_scope":scope})),
false, false,
).unwrap();
let plan = condition_plan(&selection(2, 2), &rule, &[position(1), position(2)], &market, &constraints);
let protected = plan.rows.iter().find(|row| row.symbol == symbol(1)).unwrap();
let normal = plan.rows.iter().find(|row| row.symbol == symbol(2)).unwrap();
assert_eq!(normal.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
if cause == "automatic_trade_locked" || cause == "buy_fill_protection" {
assert_eq!(protected.side, None, "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.target_quantity, 1000.into(), "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.status, "AUTOMATIC_TRADE_PROTECTED", "{scope:?}/{cause}: {plan:?}");
} else {
assert_eq!(protected.side, Some(OrderSide::Sell), "{scope:?}/{cause}: {plan:?}");
assert_eq!(protected.target_quantity, Decimal::ZERO, "{scope:?}/{cause}: {plan:?}");
}
}
}
}
#[test]
fn independent_exit_quote_priority_does_not_bypass_t_plus_one_or_price_validation() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1);
market[0].last_price = 9.into(); market[0].volume = None;
let constraints = StockPoolDecisionConstraints { default_stop_loss: Some(Decimal::new(5,2)), ..Default::default() };
for closable in [0, 400, 1000] {
let mut held = position(1); held.closable_quantity = Decimal::from(closable);
let plan = condition_plan(&selection(1,1), &rule, &[held], &market, &constraints);
let row = plan.rows.iter().find(|row|row.symbol==symbol(1)).unwrap();
assert_eq!(row.delta_quantity, -Decimal::from(closable), "{plan:?}");
assert_eq!(row.target_quantity, Decimal::from(1000-closable), "{plan:?}");
}
market[0].last_price = Decimal::ZERO;
assert!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err().contains("execution quote is invalid"));
}
#[test]
fn ordinary_sell_keeps_required_quote_failures_and_zero_stop_is_not_an_exit() {
let rule = normalize_stock_pool_execution_rule(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>1000"})), false, false).unwrap();
let mut market = quotes(1); market[0].last_price = 9.into(); market[0].volume = None;
for stop in [None, Some(Decimal::ZERO)] {
let constraints = StockPoolDecisionConstraints { default_stop_loss: stop, ..Default::default() };
assert_eq!(condition_plan_result(&selection(1,1), &rule, &[position(1)], &market, &constraints).unwrap_err(), "condition requires volume");
}
}
#[test]
fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate() {
let market = quotes(2);
for (field, threshold) in [("price", "10"), ("last", "10"), ("change_pct", "0"), ("volume", "1000000"), ("amount", "10000000"), ("bid1", "10"), ("ask1", "10")] {
for (operator, matched) in [(">",false), (">=",true), ("<",false), ("<=",true), ("==",true), ("!=",false)] {
for scope in [QuoteConditionScope::PerSymbol, QuoteConditionScope::AllTargets, QuoteConditionScope::AnyTarget] {
for side in [OrderSide::Buy, OrderSide::Sell] {
let condition = format!("{field}{operator}{threshold}");
let config = if side == OrderSide::Buy {
json!({"trigger_mode":"condition","buy_condition":condition,"buy_condition_scope":scope})
} else {
json!({"sell_trigger_mode":"condition","sell_condition":condition,"sell_condition_scope":scope})
};
let rule = normalize_stock_pool_execution_rule(Some(&config), false, false).unwrap();
let held = if side == OrderSide::Sell { vec![position(1),position(2)] } else { vec![] };
let plan = condition_plan(&selection(2,2), &rule, &held, &market, &StockPoolDecisionConstraints::default());
assert_eq!(plan.rows.iter().map(|row| &row.symbol).collect::<BTreeSet<_>>().len(), plan.rows.len(), "duplicate target ownership: {plan:?}");
let orders = plan.rows.iter().filter(|row|row.side==Some(side)).count();
assert_eq!(orders, if matched {2} else {0}, "{side:?}/{scope:?}/{condition}: {plan:?}");
if side == OrderSide::Sell && matched {
assert_eq!(plan.estimated_sell_amount, Decimal::from(20000), "{plan:?}");
assert_eq!(plan.estimated_cash_after, Decimal::from(40000), "{plan:?}");
}
}
}
}
}
}
#[test]
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
let mut constraints = StockPoolDecisionConstraints::default();
@@ -445,6 +445,31 @@ fn actual_fill_protection_is_evaluated_on_execution_date() {
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
}
#[test]
fn ordinary_sell_has_one_order_owner_before_broker_execution() {
let data = data(false);
let broker = broker(false);
let mut account = PortfolioState::new(20_000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let mut intent = contract(day(2), 1, false);
intent.rule.sell_trigger_mode = POOL_SELL_CONDITION.into();
intent.rule.sell_condition = "price>0".into();
let report = broker.execute_with_event_dates(
day(5), day(2), day(2), &mut account, &data, &decision(intent),
).unwrap();
let sells=report.fill_events.iter().filter(|row|row.symbol==code(1)).collect::<Vec<_>>();
assert_eq!(sells.len(),1,"{report:?}");
assert_eq!(sells[0].quantity,1000,"{report:?}");
let owners=report.order_events.iter().filter(|row|row.symbol==code(1)).map(|row|row.order_id).collect::<BTreeSet<_>>();
assert_eq!(owners.len(),1,"{report:?}");
assert_eq!(account.position(&code(1)).map(|row|row.quantity).unwrap_or(0),0);
// The replacement may enter only after the single sell has settled.
let replacement=report.fill_events.iter().find(|row|row.symbol==code(2)).unwrap();
assert_eq!(replacement.quantity,3000,"{report:?}");
assert_eq!(report.account_events[0].cash_after,40000.);
assert_eq!(report.account_events[1].cash_before,40000.);
}
#[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false);